use std::collections::HashSet;
use std::path::Path;

use rocksdb::{
    ColumnFamily, ColumnFamilyDescriptor, DB, DBCompressionType, Direction, IteratorMode, Options,
    WriteBatch,
};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use sha2::Digest;

use crate::blob_store::{ExternalRawStoreDb, ExternalRepoBytesDb, RawObjectStore};
use crate::data_model::common::der_take_tlv;
use crate::data_model::rc::{AsResourceSet, IpResourceSet};

pub const CF_REPOSITORY_VIEW: &str = "repository_view";
pub const CF_RAW_BY_HASH: &str = "raw_by_hash";
pub const CF_RAW_BLOB: &str = "raw_blob";
pub const CF_VCIR: &str = "vcir";
pub const CF_MANIFEST_REPLAY_META: &str = "manifest_replay_meta";
pub const CF_AUDIT_RULE_INDEX: &str = "audit_rule_index";
pub const CF_RRDP_SOURCE: &str = "rrdp_source";
pub const CF_RRDP_SOURCE_MEMBER: &str = "rrdp_source_member";
pub const CF_RRDP_URI_OWNER: &str = "rrdp_uri_owner";

pub const ALL_COLUMN_FAMILY_NAMES: &[&str] = &[
    CF_REPOSITORY_VIEW,
    CF_RAW_BY_HASH,
    CF_RAW_BLOB,
    CF_VCIR,
    CF_MANIFEST_REPLAY_META,
    CF_AUDIT_RULE_INDEX,
    CF_RRDP_SOURCE,
    CF_RRDP_SOURCE_MEMBER,
    CF_RRDP_URI_OWNER,
];

const REPOSITORY_VIEW_KEY_PREFIX: &str = "repo_view:";
const RAW_BY_HASH_KEY_PREFIX: &str = "rawbyhash:";
const RAW_BLOB_KEY_PREFIX: &str = "rawblob:";
const VCIR_KEY_PREFIX: &str = "vcir:";
const MANIFEST_REPLAY_META_KEY_PREFIX: &str = "manifest_replay_meta:";
const AUDIT_ROA_RULE_KEY_PREFIX: &str = "audit:roa_rule:";
const AUDIT_ASPA_RULE_KEY_PREFIX: &str = "audit:aspa_rule:";
const AUDIT_ROUTER_KEY_RULE_KEY_PREFIX: &str = "audit:router_key_rule:";
const RRDP_SOURCE_KEY_PREFIX: &str = "rrdp_source:";
const RRDP_SOURCE_MEMBER_KEY_PREFIX: &str = "rrdp_source_member:";
const RRDP_URI_OWNER_KEY_PREFIX: &str = "rrdp_uri_owner:";

const WORK_DB_BLOB_MODE_ENV: &str = "RPKI_WORK_DB_BLOB_MODE";

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum WorkDbBlobMode {
    Current,
    Disabled,
    Lz4,
}

fn parse_work_db_blob_mode(raw: &str) -> Option<WorkDbBlobMode> {
    match raw.trim().to_ascii_lowercase().as_str() {
        "" | "default" => Some(default_work_db_blob_mode()),
        "current" | "legacy" => Some(WorkDbBlobMode::Current),
        "disabled" | "disable" | "off" | "none" | "no_blob" | "no-blob" => {
            Some(WorkDbBlobMode::Disabled)
        }
        "lz4" | "blob_lz4" | "blob-lz4" => Some(WorkDbBlobMode::Lz4),
        _ => None,
    }
}

fn default_work_db_blob_mode() -> WorkDbBlobMode {
    WorkDbBlobMode::Disabled
}

fn work_db_blob_mode_from_env() -> WorkDbBlobMode {
    let Ok(raw) = std::env::var(WORK_DB_BLOB_MODE_ENV) else {
        return default_work_db_blob_mode();
    };
    match parse_work_db_blob_mode(&raw) {
        Some(mode) => mode,
        None => {
            eprintln!(
                "warning: unsupported {WORK_DB_BLOB_MODE_ENV}={raw:?}; using default work-db blobdb mode"
            );
            default_work_db_blob_mode()
        }
    }
}

fn configure_work_db_options(opts: &mut Options, blob_mode: WorkDbBlobMode) {
    opts.set_compression_type(DBCompressionType::Lz4);
    match blob_mode {
        WorkDbBlobMode::Current => enable_blobdb_current(opts),
        WorkDbBlobMode::Disabled => {}
        WorkDbBlobMode::Lz4 => {
            enable_blobdb_current(opts);
            opts.set_blob_compression_type(DBCompressionType::Lz4);
        }
    }
}

fn cf_opts(blob_mode: WorkDbBlobMode) -> Options {
    let mut opts = Options::default();
    configure_work_db_options(&mut opts, blob_mode);
    opts
}

fn raw_blob_key(sha256_hex: &str) -> String {
    format!("{RAW_BLOB_KEY_PREFIX}{sha256_hex}")
}

pub fn column_family_descriptors() -> Vec<ColumnFamilyDescriptor> {
    column_family_descriptors_for_blob_mode(work_db_blob_mode_from_env())
}

fn column_family_descriptors_for_blob_mode(
    blob_mode: WorkDbBlobMode,
) -> Vec<ColumnFamilyDescriptor> {
    ALL_COLUMN_FAMILY_NAMES
        .iter()
        .map(|name| ColumnFamilyDescriptor::new(*name, cf_opts(blob_mode)))
        .collect()
}

#[derive(Debug, thiserror::Error)]
pub enum StorageError {
    #[error("rocksdb error: {0}")]
    RocksDb(String),

    #[error("missing column family: {0}")]
    MissingColumnFamily(&'static str),

    #[error("cbor codec error for {entity}: {detail}")]
    Codec {
        entity: &'static str,
        detail: String,
    },

    #[error("invalid {entity}: {detail}")]
    InvalidData {
        entity: &'static str,
        detail: String,
    },
}

pub type StorageResult<T> = Result<T, StorageError>;

pub struct RocksStore {
    db: DB,
    external_raw_store: Option<ExternalRawStoreDb>,
    external_repo_bytes: Option<ExternalRepoBytesDb>,
}

pub mod pack {
    pub use super::{PackFile, PackTime};
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RrdpDeltaOp {
    Upsert { rsync_uri: String, bytes: Vec<u8> },
    Delete { rsync_uri: String },
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RepositoryViewState {
    Present,
    Withdrawn,
    Replaced,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryViewEntry {
    pub rsync_uri: String,
    pub current_hash: Option<String>,
    pub repository_source: Option<String>,
    pub object_type: Option<String>,
    pub state: RepositoryViewState,
}

impl RepositoryViewEntry {
    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_non_empty("repository_view.rsync_uri", &self.rsync_uri)?;
        if let Some(source) = &self.repository_source {
            validate_non_empty("repository_view.repository_source", source)?;
        }
        match self.state {
            RepositoryViewState::Present | RepositoryViewState::Replaced => {
                let hash = self
                    .current_hash
                    .as_deref()
                    .ok_or(StorageError::InvalidData {
                        entity: "repository_view",
                        detail: "current_hash is required when state is present or replaced"
                            .to_string(),
                    })?;
                validate_sha256_hex("repository_view.current_hash", hash)?;
            }
            RepositoryViewState::Withdrawn => {
                if let Some(hash) = &self.current_hash {
                    validate_sha256_hex("repository_view.current_hash", hash)?;
                }
            }
        }
        Ok(())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RawByHashEntry {
    pub sha256_hex: String,
    pub bytes: Vec<u8>,
    pub origin_uris: Vec<String>,
    pub object_type: Option<String>,
    pub encoding: Option<String>,
}

impl RawByHashEntry {
    pub fn from_bytes(sha256_hex: impl Into<String>, bytes: Vec<u8>) -> Self {
        Self {
            sha256_hex: sha256_hex.into(),
            bytes,
            origin_uris: Vec::new(),
            object_type: None,
            encoding: None,
        }
    }

    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_sha256_hex("raw_by_hash.sha256_hex", &self.sha256_hex)?;
        if self.bytes.is_empty() {
            return Err(StorageError::InvalidData {
                entity: "raw_by_hash",
                detail: "bytes must not be empty".to_string(),
            });
        }
        let computed = hex::encode(compute_sha256_32(&self.bytes));
        if computed != self.sha256_hex.to_ascii_lowercase() {
            return Err(StorageError::InvalidData {
                entity: "raw_by_hash",
                detail: "sha256_hex does not match bytes".to_string(),
            });
        }
        let mut seen = HashSet::with_capacity(self.origin_uris.len());
        for uri in &self.origin_uris {
            validate_non_empty("raw_by_hash.origin_uris[]", uri)?;
            if !seen.insert(uri.as_str()) {
                return Err(StorageError::InvalidData {
                    entity: "raw_by_hash",
                    detail: format!("duplicate origin URI: {uri}"),
                });
            }
        }
        Ok(())
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CurrentObjectWithHash {
    pub current_hash_hex: String,
    pub current_hash: [u8; 32],
    pub bytes: Vec<u8>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidatedManifestMeta {
    pub validated_manifest_number: Vec<u8>,
    pub validated_manifest_this_update: PackTime,
    pub validated_manifest_next_update: PackTime,
}

impl ValidatedManifestMeta {
    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_manifest_number_be(
            "validated_manifest_meta.validated_manifest_number",
            &self.validated_manifest_number,
        )?;
        let this_update = parse_time(
            "validated_manifest_meta.validated_manifest_this_update",
            &self.validated_manifest_this_update,
        )?;
        let next_update = parse_time(
            "validated_manifest_meta.validated_manifest_next_update",
            &self.validated_manifest_next_update,
        )?;
        if next_update < this_update {
            return Err(StorageError::InvalidData {
                entity: "validated_manifest_meta",
                detail: "validated_manifest_next_update must be >= validated_manifest_this_update"
                    .to_string(),
            });
        }
        Ok(())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestReplayMeta {
    pub manifest_rsync_uri: String,
    pub manifest_number_be: Vec<u8>,
    pub manifest_this_update: PackTime,
    pub manifest_sha256: Vec<u8>,
    pub updated_at_validation_time: PackTime,
}

impl ManifestReplayMeta {
    pub fn from_vcir(vcir: &ValidatedCaInstanceResult) -> Self {
        Self {
            manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
            manifest_number_be: vcir
                .validated_manifest_meta
                .validated_manifest_number
                .clone(),
            manifest_this_update: vcir
                .validated_manifest_meta
                .validated_manifest_this_update
                .clone(),
            manifest_sha256: vcir.ccr_manifest_projection.manifest_sha256.clone(),
            updated_at_validation_time: vcir.last_successful_validation_time.clone(),
        }
    }

    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_non_empty(
            "manifest_replay_meta.manifest_rsync_uri",
            &self.manifest_rsync_uri,
        )?;
        validate_manifest_number_be(
            "manifest_replay_meta.manifest_number_be",
            &self.manifest_number_be,
        )?;
        parse_time(
            "manifest_replay_meta.manifest_this_update",
            &self.manifest_this_update,
        )?;
        validate_sha256_digest_bytes(
            "manifest_replay_meta.manifest_sha256",
            &self.manifest_sha256,
        )?;
        parse_time(
            "manifest_replay_meta.updated_at_validation_time",
            &self.updated_at_validation_time,
        )?;
        Ok(())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VcirCcrManifestProjection {
    pub manifest_rsync_uri: String,
    pub manifest_sha256: Vec<u8>,
    pub manifest_size: u64,
    pub manifest_ee_aki: Vec<u8>,
    pub manifest_number_be: Vec<u8>,
    pub manifest_this_update: PackTime,
    pub manifest_sia_locations_der: Vec<Vec<u8>>,
    pub subordinate_skis: Vec<Vec<u8>>,
}

impl VcirCcrManifestProjection {
    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_non_empty(
            "vcir.ccr_manifest_projection.manifest_rsync_uri",
            &self.manifest_rsync_uri,
        )?;
        validate_sha256_digest_bytes(
            "vcir.ccr_manifest_projection.manifest_sha256",
            &self.manifest_sha256,
        )?;
        if self.manifest_size < 1000 {
            return Err(StorageError::InvalidData {
                entity: "vcir.ccr_manifest_projection.manifest_size",
                detail: format!("must be >= 1000, got {}", self.manifest_size),
            });
        }
        validate_fixed_len_bytes(
            "vcir.ccr_manifest_projection.manifest_ee_aki",
            &self.manifest_ee_aki,
            20,
        )?;
        validate_manifest_number_be(
            "vcir.ccr_manifest_projection.manifest_number_be",
            &self.manifest_number_be,
        )?;
        parse_time(
            "vcir.ccr_manifest_projection.manifest_this_update",
            &self.manifest_this_update,
        )?;
        if self.manifest_sia_locations_der.is_empty() {
            return Err(StorageError::InvalidData {
                entity: "vcir.ccr_manifest_projection.manifest_sia_locations_der",
                detail: "must contain at least one AccessDescription".to_string(),
            });
        }
        for location in &self.manifest_sia_locations_der {
            validate_full_der_with_tag(
                "vcir.ccr_manifest_projection.manifest_sia_locations_der[]",
                location,
                Some(0x30),
            )?;
        }
        validate_sorted_unique_fixed_len_bytes(
            "vcir.ccr_manifest_projection.subordinate_skis",
            &self.subordinate_skis,
            20,
        )?;
        Ok(())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VcirInstanceGate {
    pub manifest_next_update: PackTime,
    pub current_crl_next_update: PackTime,
    pub self_ca_not_after: PackTime,
    pub instance_effective_until: PackTime,
}

impl VcirInstanceGate {
    pub fn validate_internal(&self) -> StorageResult<()> {
        let manifest_next_update = parse_time(
            "vcir.instance_gate.manifest_next_update",
            &self.manifest_next_update,
        )?;
        let current_crl_next_update = parse_time(
            "vcir.instance_gate.current_crl_next_update",
            &self.current_crl_next_update,
        )?;
        let self_ca_not_after = parse_time(
            "vcir.instance_gate.self_ca_not_after",
            &self.self_ca_not_after,
        )?;
        let instance_effective_until = parse_time(
            "vcir.instance_gate.instance_effective_until",
            &self.instance_effective_until,
        )?;
        let expected = manifest_next_update
            .min(current_crl_next_update)
            .min(self_ca_not_after);
        if instance_effective_until != expected {
            return Err(StorageError::InvalidData {
                entity: "vcir.instance_gate",
                detail: "instance_effective_until must equal min(manifest_next_update, current_crl_next_update, self_ca_not_after)".to_string(),
            });
        }
        Ok(())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VcirChildEntry {
    pub child_manifest_rsync_uri: String,
    pub child_cert_rsync_uri: String,
    pub child_cert_hash: String,
    pub child_ski: String,
    pub child_rsync_base_uri: String,
    pub child_publication_point_rsync_uri: String,
    pub child_rrdp_notification_uri: Option<String>,
    pub child_effective_ip_resources: Option<IpResourceSet>,
    pub child_effective_as_resources: Option<AsResourceSet>,
    pub accepted_at_validation_time: PackTime,
}

impl VcirChildEntry {
    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_non_empty(
            "vcir.child_entries[].child_manifest_rsync_uri",
            &self.child_manifest_rsync_uri,
        )?;
        validate_non_empty(
            "vcir.child_entries[].child_cert_rsync_uri",
            &self.child_cert_rsync_uri,
        )?;
        validate_sha256_hex(
            "vcir.child_entries[].child_cert_hash",
            &self.child_cert_hash,
        )?;
        validate_non_empty("vcir.child_entries[].child_ski", &self.child_ski)?;
        validate_non_empty(
            "vcir.child_entries[].child_rsync_base_uri",
            &self.child_rsync_base_uri,
        )?;
        validate_non_empty(
            "vcir.child_entries[].child_publication_point_rsync_uri",
            &self.child_publication_point_rsync_uri,
        )?;
        if let Some(uri) = &self.child_rrdp_notification_uri {
            validate_non_empty("vcir.child_entries[].child_rrdp_notification_uri", uri)?;
        }
        parse_time(
            "vcir.child_entries[].accepted_at_validation_time",
            &self.accepted_at_validation_time,
        )?;
        Ok(())
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VcirOutputType {
    Vrp,
    Aspa,
    RouterKey,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VcirLocalOutput {
    pub output_id: String,
    pub output_type: VcirOutputType,
    pub item_effective_until: PackTime,
    pub source_object_uri: String,
    pub source_object_type: String,
    pub source_object_hash: String,
    pub source_ee_cert_hash: String,
    pub payload_json: String,
    pub rule_hash: String,
    pub validation_path_hint: Vec<String>,
}

impl VcirLocalOutput {
    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_non_empty("vcir.local_outputs[].output_id", &self.output_id)?;
        parse_time(
            "vcir.local_outputs[].item_effective_until",
            &self.item_effective_until,
        )?;
        validate_non_empty(
            "vcir.local_outputs[].source_object_uri",
            &self.source_object_uri,
        )?;
        validate_non_empty(
            "vcir.local_outputs[].source_object_type",
            &self.source_object_type,
        )?;
        validate_sha256_hex(
            "vcir.local_outputs[].source_object_hash",
            &self.source_object_hash,
        )?;
        validate_sha256_hex(
            "vcir.local_outputs[].source_ee_cert_hash",
            &self.source_ee_cert_hash,
        )?;
        validate_sha256_hex("vcir.local_outputs[].rule_hash", &self.rule_hash)?;
        validate_non_empty("vcir.local_outputs[].payload_json", &self.payload_json)?;
        for hint in &self.validation_path_hint {
            validate_non_empty("vcir.local_outputs[].validation_path_hint[]", hint)?;
        }
        Ok(())
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VcirArtifactRole {
    Manifest,
    CurrentCrl,
    ChildCaCert,
    SignedObject,
    EeCert,
    IssuerCert,
    Tal,
    TrustAnchorCert,
    Other,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VcirArtifactKind {
    Cer,
    Crl,
    Mft,
    Roa,
    Aspa,
    Gbr,
    Tal,
    Other,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VcirArtifactValidationStatus {
    Accepted,
    Rejected,
    WarningOnly,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VcirRelatedArtifact {
    pub artifact_role: VcirArtifactRole,
    pub artifact_kind: VcirArtifactKind,
    pub uri: Option<String>,
    pub sha256: String,
    pub object_type: Option<String>,
    pub validation_status: VcirArtifactValidationStatus,
}

impl VcirRelatedArtifact {
    pub fn validate_internal(&self) -> StorageResult<()> {
        if let Some(uri) = &self.uri {
            validate_non_empty("vcir.related_artifacts[].uri", uri)?;
        }
        validate_sha256_hex("vcir.related_artifacts[].sha256", &self.sha256)?;
        Ok(())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VcirSummary {
    pub local_vrp_count: u32,
    pub local_aspa_count: u32,
    pub local_router_key_count: u32,
    pub child_count: u32,
    pub accepted_object_count: u32,
    pub rejected_object_count: u32,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VcirAuditSummary {
    pub failed_fetch_eligible: bool,
    pub last_failed_fetch_reason: Option<String>,
    pub warning_count: u32,
    pub audit_flags: Vec<String>,
}

impl VcirAuditSummary {
    pub fn validate_internal(&self) -> StorageResult<()> {
        if let Some(reason) = &self.last_failed_fetch_reason {
            validate_non_empty("vcir.audit_summary.last_failed_fetch_reason", reason)?;
        }
        for flag in &self.audit_flags {
            validate_non_empty("vcir.audit_summary.audit_flags[]", flag)?;
        }
        Ok(())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidatedCaInstanceResult {
    pub manifest_rsync_uri: String,
    pub parent_manifest_rsync_uri: Option<String>,
    pub tal_id: String,
    pub ca_subject_name: String,
    pub ca_ski: String,
    pub issuer_ski: String,
    pub last_successful_validation_time: PackTime,
    pub current_manifest_rsync_uri: String,
    pub current_crl_rsync_uri: String,
    pub validated_manifest_meta: ValidatedManifestMeta,
    pub ccr_manifest_projection: VcirCcrManifestProjection,
    pub instance_gate: VcirInstanceGate,
    pub child_entries: Vec<VcirChildEntry>,
    pub local_outputs: Vec<VcirLocalOutput>,
    pub related_artifacts: Vec<VcirRelatedArtifact>,
    pub summary: VcirSummary,
    pub audit_summary: VcirAuditSummary,
}

impl ValidatedCaInstanceResult {
    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_non_empty("vcir.manifest_rsync_uri", &self.manifest_rsync_uri)?;
        if let Some(parent_manifest_rsync_uri) = &self.parent_manifest_rsync_uri {
            validate_non_empty("vcir.parent_manifest_rsync_uri", parent_manifest_rsync_uri)?;
        }
        validate_non_empty("vcir.tal_id", &self.tal_id)?;
        validate_non_empty("vcir.ca_subject_name", &self.ca_subject_name)?;
        validate_non_empty("vcir.ca_ski", &self.ca_ski)?;
        validate_non_empty("vcir.issuer_ski", &self.issuer_ski)?;
        parse_time(
            "vcir.last_successful_validation_time",
            &self.last_successful_validation_time,
        )?;
        validate_non_empty(
            "vcir.current_manifest_rsync_uri",
            &self.current_manifest_rsync_uri,
        )?;
        validate_non_empty("vcir.current_crl_rsync_uri", &self.current_crl_rsync_uri)?;
        self.validated_manifest_meta.validate_internal()?;
        self.ccr_manifest_projection.validate_internal()?;
        self.instance_gate.validate_internal()?;

        let expected_manifest_next = self
            .validated_manifest_meta
            .validated_manifest_next_update
            .parse()
            .map_err(|detail| StorageError::InvalidData {
                entity: "vcir",
                detail: format!(
                    "validated_manifest_meta.validated_manifest_next_update invalid: {detail}"
                ),
            })?;
        let instance_manifest_next =
            self.instance_gate
                .manifest_next_update
                .parse()
                .map_err(|detail| StorageError::InvalidData {
                    entity: "vcir",
                    detail: format!("instance_gate.manifest_next_update invalid: {detail}"),
                })?;
        if expected_manifest_next != instance_manifest_next {
            return Err(StorageError::InvalidData {
                entity: "vcir",
                detail: "instance_gate.manifest_next_update must equal validated_manifest_meta.validated_manifest_next_update".to_string(),
            });
        }

        let mut child_manifests = HashSet::with_capacity(self.child_entries.len());
        for child in &self.child_entries {
            child.validate_internal()?;
            if !child_manifests.insert(child.child_manifest_rsync_uri.as_str()) {
                return Err(StorageError::InvalidData {
                    entity: "vcir",
                    detail: format!(
                        "duplicate child_manifest_rsync_uri: {}",
                        child.child_manifest_rsync_uri
                    ),
                });
            }
        }

        let mut output_ids = HashSet::with_capacity(self.local_outputs.len());
        let mut vrp_count = 0u32;
        let mut aspa_count = 0u32;
        let mut router_key_count = 0u32;
        for output in &self.local_outputs {
            output.validate_internal()?;
            if !output_ids.insert(output.output_id.as_str()) {
                return Err(StorageError::InvalidData {
                    entity: "vcir",
                    detail: format!("duplicate output_id: {}", output.output_id),
                });
            }
            match output.output_type {
                VcirOutputType::Vrp => vrp_count += 1,
                VcirOutputType::Aspa => aspa_count += 1,
                VcirOutputType::RouterKey => router_key_count += 1,
            }
        }
        if self.summary.local_vrp_count != vrp_count {
            return Err(StorageError::InvalidData {
                entity: "vcir.summary",
                detail: format!(
                    "local_vrp_count={} does not match local_outputs count {}",
                    self.summary.local_vrp_count, vrp_count
                ),
            });
        }
        if self.summary.local_aspa_count != aspa_count {
            return Err(StorageError::InvalidData {
                entity: "vcir.summary",
                detail: format!(
                    "local_aspa_count={} does not match local_outputs count {}",
                    self.summary.local_aspa_count, aspa_count
                ),
            });
        }
        if self.summary.local_router_key_count != router_key_count {
            return Err(StorageError::InvalidData {
                entity: "vcir.summary",
                detail: format!(
                    "local_router_key_count={} does not match local_outputs count {}",
                    self.summary.local_router_key_count, router_key_count
                ),
            });
        }
        if self.summary.child_count != self.child_entries.len() as u32 {
            return Err(StorageError::InvalidData {
                entity: "vcir.summary",
                detail: format!(
                    "child_count={} does not match child_entries length {}",
                    self.summary.child_count,
                    self.child_entries.len()
                ),
            });
        }

        for artifact in &self.related_artifacts {
            artifact.validate_internal()?;
        }
        self.audit_summary.validate_internal()?;
        Ok(())
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditRuleKind {
    Roa,
    Aspa,
    RouterKey,
}

impl AuditRuleKind {
    fn key_prefix(self) -> &'static str {
        match self {
            Self::Roa => AUDIT_ROA_RULE_KEY_PREFIX,
            Self::Aspa => AUDIT_ASPA_RULE_KEY_PREFIX,
            Self::RouterKey => AUDIT_ROUTER_KEY_RULE_KEY_PREFIX,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditRuleIndexEntry {
    pub kind: AuditRuleKind,
    pub rule_hash: String,
    pub manifest_rsync_uri: String,
    pub source_object_uri: String,
    pub source_object_hash: String,
    pub output_id: String,
    pub item_effective_until: PackTime,
}

impl AuditRuleIndexEntry {
    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_sha256_hex("audit_rule_index.rule_hash", &self.rule_hash)?;
        validate_non_empty(
            "audit_rule_index.manifest_rsync_uri",
            &self.manifest_rsync_uri,
        )?;
        validate_non_empty(
            "audit_rule_index.source_object_uri",
            &self.source_object_uri,
        )?;
        validate_sha256_hex(
            "audit_rule_index.source_object_hash",
            &self.source_object_hash,
        )?;
        validate_non_empty("audit_rule_index.output_id", &self.output_id)?;
        parse_time(
            "audit_rule_index.item_effective_until",
            &self.item_effective_until,
        )?;
        Ok(())
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RrdpSourceSyncState {
    Empty,
    SnapshotOnly,
    DeltaReady,
    Error,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RrdpSourceRecord {
    pub notify_uri: String,
    pub last_session_id: Option<String>,
    pub last_serial: Option<u64>,
    pub first_seen_at: PackTime,
    pub last_seen_at: PackTime,
    pub last_sync_at: Option<PackTime>,
    pub sync_state: RrdpSourceSyncState,
    pub last_snapshot_uri: Option<String>,
    pub last_snapshot_hash: Option<String>,
    pub last_error: Option<String>,
}

impl RrdpSourceRecord {
    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_non_empty("rrdp_source.notify_uri", &self.notify_uri)?;
        if let Some(session_id) = &self.last_session_id {
            validate_non_empty("rrdp_source.last_session_id", session_id)?;
        }
        parse_time("rrdp_source.first_seen_at", &self.first_seen_at)?;
        parse_time("rrdp_source.last_seen_at", &self.last_seen_at)?;
        if let Some(last_sync_at) = &self.last_sync_at {
            parse_time("rrdp_source.last_sync_at", last_sync_at)?;
        }
        if let Some(last_snapshot_uri) = &self.last_snapshot_uri {
            validate_non_empty("rrdp_source.last_snapshot_uri", last_snapshot_uri)?;
        }
        if let Some(last_snapshot_hash) = &self.last_snapshot_hash {
            validate_sha256_hex("rrdp_source.last_snapshot_hash", last_snapshot_hash)?;
        }
        if let Some(last_error) = &self.last_error {
            validate_non_empty("rrdp_source.last_error", last_error)?;
        }
        Ok(())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RrdpSourceMemberRecord {
    pub notify_uri: String,
    pub rsync_uri: String,
    pub current_hash: Option<String>,
    pub object_type: Option<String>,
    pub present: bool,
    pub last_confirmed_session_id: String,
    pub last_confirmed_serial: u64,
    pub last_changed_at: PackTime,
}

impl RrdpSourceMemberRecord {
    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_non_empty("rrdp_source_member.notify_uri", &self.notify_uri)?;
        validate_non_empty("rrdp_source_member.rsync_uri", &self.rsync_uri)?;
        validate_non_empty(
            "rrdp_source_member.last_confirmed_session_id",
            &self.last_confirmed_session_id,
        )?;
        if self.present {
            let hash = self
                .current_hash
                .as_deref()
                .ok_or(StorageError::InvalidData {
                    entity: "rrdp_source_member",
                    detail: "current_hash is required when present=true".to_string(),
                })?;
            validate_sha256_hex("rrdp_source_member.current_hash", hash)?;
        } else if let Some(hash) = &self.current_hash {
            validate_sha256_hex("rrdp_source_member.current_hash", hash)?;
        }
        parse_time("rrdp_source_member.last_changed_at", &self.last_changed_at)?;
        Ok(())
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RrdpUriOwnerState {
    Active,
    Conflict,
    Withdrawn,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RrdpUriOwnerRecord {
    pub rsync_uri: String,
    pub notify_uri: String,
    pub current_hash: Option<String>,
    pub last_confirmed_session_id: String,
    pub last_confirmed_serial: u64,
    pub last_changed_at: PackTime,
    pub owner_state: RrdpUriOwnerState,
}

impl RrdpUriOwnerRecord {
    pub fn validate_internal(&self) -> StorageResult<()> {
        validate_non_empty("rrdp_uri_owner.rsync_uri", &self.rsync_uri)?;
        validate_non_empty("rrdp_uri_owner.notify_uri", &self.notify_uri)?;
        validate_non_empty(
            "rrdp_uri_owner.last_confirmed_session_id",
            &self.last_confirmed_session_id,
        )?;
        if let Some(hash) = &self.current_hash {
            validate_sha256_hex("rrdp_uri_owner.current_hash", hash)?;
        }
        parse_time("rrdp_uri_owner.last_changed_at", &self.last_changed_at)?;
        Ok(())
    }
}

impl RocksStore {
    pub fn open(path: &Path) -> StorageResult<Self> {
        let mut base_opts = Options::default();
        base_opts.create_if_missing(true);
        base_opts.create_missing_column_families(true);
        let blob_mode = work_db_blob_mode_from_env();
        configure_work_db_options(&mut base_opts, blob_mode);

        let db = DB::open_cf_descriptors(
            &base_opts,
            path,
            column_family_descriptors_for_blob_mode(blob_mode),
        )
        .map_err(|e| StorageError::RocksDb(e.to_string()))?;

        Ok(Self {
            db,
            external_raw_store: None,
            external_repo_bytes: None,
        })
    }

    pub fn open_with_external_raw_store(path: &Path, raw_store_path: &Path) -> StorageResult<Self> {
        Self::open_with_external_stores(path, Some(raw_store_path), None)
    }

    pub fn open_with_external_repo_bytes(
        path: &Path,
        repo_bytes_path: &Path,
    ) -> StorageResult<Self> {
        Self::open_with_external_stores(path, None, Some(repo_bytes_path))
    }

    pub fn open_with_external_stores(
        path: &Path,
        raw_store_path: Option<&Path>,
        repo_bytes_path: Option<&Path>,
    ) -> StorageResult<Self> {
        let mut store = Self::open(path)?;
        if let Some(raw_store_path) = raw_store_path {
            store.external_raw_store = Some(ExternalRawStoreDb::open(raw_store_path)?);
        }
        if let Some(repo_bytes_path) = repo_bytes_path {
            store.external_repo_bytes = Some(ExternalRepoBytesDb::open(repo_bytes_path)?);
        }
        Ok(store)
    }

    pub(crate) fn external_raw_store_ref(&self) -> Option<&ExternalRawStoreDb> {
        self.external_raw_store.as_ref()
    }

    pub(crate) fn external_repo_bytes_ref(&self) -> Option<&ExternalRepoBytesDb> {
        self.external_repo_bytes.as_ref()
    }

    fn cf(&self, name: &'static str) -> StorageResult<&ColumnFamily> {
        self.db
            .cf_handle(name)
            .ok_or(StorageError::MissingColumnFamily(name))
    }

    pub fn put_repository_view_entry(&self, entry: &RepositoryViewEntry) -> StorageResult<()> {
        entry.validate_internal()?;
        let cf = self.cf(CF_REPOSITORY_VIEW)?;
        let key = repository_view_key(&entry.rsync_uri);
        let value = encode_cbor(entry, "repository_view")?;
        self.db
            .put_cf(cf, key.as_bytes(), value)
            .map_err(|e| StorageError::RocksDb(e.to_string()))?;
        Ok(())
    }

    pub fn get_repository_view_entry(
        &self,
        rsync_uri: &str,
    ) -> StorageResult<Option<RepositoryViewEntry>> {
        let cf = self.cf(CF_REPOSITORY_VIEW)?;
        let key = repository_view_key(rsync_uri);
        let Some(bytes) = self
            .db
            .get_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?
        else {
            return Ok(None);
        };
        let entry = decode_cbor::<RepositoryViewEntry>(&bytes, "repository_view")?;
        entry.validate_internal()?;
        Ok(Some(entry))
    }

    pub fn delete_repository_view_entry(&self, rsync_uri: &str) -> StorageResult<()> {
        let cf = self.cf(CF_REPOSITORY_VIEW)?;
        let key = repository_view_key(rsync_uri);
        self.db
            .delete_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?;
        Ok(())
    }

    pub fn put_projection_batch(
        &self,
        repository_view_entries: &[RepositoryViewEntry],
        member_records: &[RrdpSourceMemberRecord],
        owner_records: &[RrdpUriOwnerRecord],
    ) -> StorageResult<()> {
        if repository_view_entries.is_empty()
            && member_records.is_empty()
            && owner_records.is_empty()
        {
            return Ok(());
        }

        let repo_cf = self.cf(CF_REPOSITORY_VIEW)?;
        let member_cf = self.cf(CF_RRDP_SOURCE_MEMBER)?;
        let owner_cf = self.cf(CF_RRDP_URI_OWNER)?;
        let mut batch = WriteBatch::default();

        for entry in repository_view_entries {
            entry.validate_internal()?;
            let key = repository_view_key(&entry.rsync_uri);
            let value = encode_cbor(entry, "repository_view")?;
            batch.put_cf(repo_cf, key.as_bytes(), value);
        }
        for record in member_records {
            record.validate_internal()?;
            let key = rrdp_source_member_key(&record.notify_uri, &record.rsync_uri);
            let value = encode_cbor(record, "rrdp_source_member")?;
            batch.put_cf(member_cf, key.as_bytes(), value);
        }
        for record in owner_records {
            record.validate_internal()?;
            let key = rrdp_uri_owner_key(&record.rsync_uri);
            let value = encode_cbor(record, "rrdp_uri_owner")?;
            batch.put_cf(owner_cf, key.as_bytes(), value);
        }

        self.write_batch(batch)
    }

    pub fn list_repository_view_entries_with_prefix(
        &self,
        rsync_uri_prefix: &str,
    ) -> StorageResult<Vec<RepositoryViewEntry>> {
        let cf = self.cf(CF_REPOSITORY_VIEW)?;
        let prefix = repository_view_prefix(rsync_uri_prefix);
        let mode = IteratorMode::From(prefix.as_bytes(), Direction::Forward);
        self.db
            .iterator_cf(cf, mode)
            .take_while(|res| match res {
                Ok((key, _)) => key.starts_with(prefix.as_bytes()),
                Err(_) => false,
            })
            .map(|res| {
                let (_key, value) = res.map_err(|e| StorageError::RocksDb(e.to_string()))?;
                let entry = decode_cbor::<RepositoryViewEntry>(&value, "repository_view")?;
                entry.validate_internal()?;
                Ok(entry)
            })
            .collect()
    }

    pub fn put_raw_by_hash_entry(&self, entry: &RawByHashEntry) -> StorageResult<()> {
        entry.validate_internal()?;
        if let Some(raw_store) = self.external_raw_store.as_ref() {
            return raw_store.put_raw_entry(entry);
        }
        let cf = self.cf(CF_RAW_BY_HASH)?;
        let key = raw_by_hash_key(&entry.sha256_hex);
        let value = encode_cbor(entry, "raw_by_hash")?;
        self.db
            .put_cf(cf, key.as_bytes(), value)
            .map_err(|e| StorageError::RocksDb(e.to_string()))?;
        Ok(())
    }

    pub fn put_raw_by_hash_entries_batch(&self, entries: &[RawByHashEntry]) -> StorageResult<()> {
        if entries.is_empty() {
            return Ok(());
        }
        if let Some(raw_store) = self.external_raw_store.as_ref() {
            return raw_store.put_raw_entries_batch(entries);
        }

        let cf = self.cf(CF_RAW_BY_HASH)?;
        let mut batch = WriteBatch::default();
        for entry in entries {
            entry.validate_internal()?;
            let key = raw_by_hash_key(&entry.sha256_hex);
            let value = encode_cbor(entry, "raw_by_hash")?;
            batch.put_cf(cf, key.as_bytes(), value);
        }
        self.write_batch(batch)
    }

    pub fn put_raw_by_hash_entries_batch_unchecked(
        &self,
        entries: &[RawByHashEntry],
    ) -> StorageResult<()> {
        if entries.is_empty() {
            return Ok(());
        }
        if let Some(raw_store) = self.external_raw_store.as_ref() {
            return raw_store.put_raw_entries_batch(entries);
        }

        let cf = self.cf(CF_RAW_BY_HASH)?;
        let mut batch = WriteBatch::default();
        for entry in entries {
            let key = raw_by_hash_key(&entry.sha256_hex);
            let value = encode_cbor(entry, "raw_by_hash")?;
            batch.put_cf(cf, key.as_bytes(), value);
        }
        self.write_batch(batch)
    }

    pub fn put_blob_bytes_batch(&self, blobs: &[(String, Vec<u8>)]) -> StorageResult<()> {
        if blobs.is_empty() {
            return Ok(());
        }
        if let Some(repo_bytes) = self.external_repo_bytes.as_ref() {
            return repo_bytes.put_blob_bytes_batch(blobs);
        }
        if let Some(raw_store) = self.external_raw_store.as_ref() {
            return raw_store.put_blob_bytes_batch(blobs);
        }
        let cf = self.cf(CF_RAW_BLOB)?;
        let mut batch = WriteBatch::default();
        for (sha256_hex, bytes) in blobs {
            validate_sha256_hex("raw_blob.sha256_hex", sha256_hex)?;
            if bytes.is_empty() {
                return Err(StorageError::InvalidData {
                    entity: "raw_blob",
                    detail: "bytes must not be empty".to_string(),
                });
            }
            let key = raw_blob_key(sha256_hex);
            batch.put_cf(cf, key.as_bytes(), bytes.as_slice());
        }
        self.write_batch(batch)
    }

    pub fn delete_raw_by_hash_entry(&self, sha256_hex: &str) -> StorageResult<()> {
        validate_sha256_hex("raw_by_hash.sha256_hex", sha256_hex)?;
        if let Some(raw_store) = self.external_raw_store.as_ref() {
            return raw_store.delete_raw_entry(sha256_hex);
        }
        let cf = self.cf(CF_RAW_BY_HASH)?;
        let key = raw_by_hash_key(sha256_hex);
        self.db
            .delete_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?;
        Ok(())
    }

    pub fn get_raw_by_hash_entry(&self, sha256_hex: &str) -> StorageResult<Option<RawByHashEntry>> {
        if let Some(raw_store) = self.external_raw_store.as_ref() {
            return raw_store.get_raw_entry(sha256_hex);
        }
        let cf = self.cf(CF_RAW_BY_HASH)?;
        let key = raw_by_hash_key(sha256_hex);
        let Some(bytes) = self
            .db
            .get_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?
        else {
            return Ok(None);
        };
        let entry = decode_cbor::<RawByHashEntry>(&bytes, "raw_by_hash")?;
        entry.validate_internal()?;
        Ok(Some(entry))
    }

    pub fn get_raw_by_hash_entries_batch(
        &self,
        sha256_hexes: &[String],
    ) -> StorageResult<Vec<Option<RawByHashEntry>>> {
        if sha256_hexes.is_empty() {
            return Ok(Vec::new());
        }
        if let Some(raw_store) = self.external_raw_store.as_ref() {
            return raw_store.get_raw_entries_batch(sha256_hexes);
        }

        let cf = self.cf(CF_RAW_BY_HASH)?;
        let keys: Vec<String> = sha256_hexes
            .iter()
            .map(|hash| raw_by_hash_key(hash))
            .collect();
        self.db
            .multi_get_cf(keys.iter().map(|key| (cf, key.as_bytes())))
            .into_iter()
            .map(|res| {
                let maybe = res.map_err(|e| StorageError::RocksDb(e.to_string()))?;
                match maybe {
                    Some(bytes) => {
                        let entry = decode_cbor::<RawByHashEntry>(&bytes, "raw_by_hash")?;
                        entry.validate_internal()?;
                        Ok(Some(entry))
                    }
                    None => Ok(None),
                }
            })
            .collect()
    }

    pub fn get_blob_bytes(&self, sha256_hex: &str) -> StorageResult<Option<Vec<u8>>> {
        if let Some(repo_bytes) = self.external_repo_bytes.as_ref() {
            return repo_bytes.get_blob_bytes(sha256_hex);
        }
        if let Some(raw_store) = self.external_raw_store.as_ref() {
            return raw_store.get_blob_bytes(sha256_hex);
        }
        validate_sha256_hex("raw_blob.sha256_hex", sha256_hex)?;
        let cf = self.cf(CF_RAW_BLOB)?;
        let key = raw_blob_key(sha256_hex);
        if let Some(bytes) = self
            .db
            .get_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?
        {
            return Ok(Some(bytes));
        }
        self.get_raw_by_hash_entry(sha256_hex)
            .map(|entry| entry.map(|entry| entry.bytes))
    }

    pub fn get_blob_bytes_batch(
        &self,
        sha256_hexes: &[String],
    ) -> StorageResult<Vec<Option<Vec<u8>>>> {
        if sha256_hexes.is_empty() {
            return Ok(Vec::new());
        }
        if let Some(repo_bytes) = self.external_repo_bytes.as_ref() {
            return repo_bytes.get_blob_bytes_batch(sha256_hexes);
        }
        if let Some(raw_store) = self.external_raw_store.as_ref() {
            return raw_store.get_blob_bytes_batch(sha256_hexes);
        }

        let cf = self.cf(CF_RAW_BLOB)?;
        let keys: Vec<String> = sha256_hexes
            .iter()
            .map(|hash| {
                validate_sha256_hex("raw_blob.sha256_hex", hash)?;
                Ok::<String, StorageError>(raw_blob_key(hash))
            })
            .collect::<Result<_, _>>()?;
        let blob_results: Vec<Option<Vec<u8>>> = self
            .db
            .multi_get_cf(keys.iter().map(|key| (cf, key.as_bytes())))
            .into_iter()
            .map(|res| res.map_err(|e| StorageError::RocksDb(e.to_string())))
            .collect::<Result<_, _>>()?;

        let mut out = Vec::with_capacity(sha256_hexes.len());
        for (sha256_hex, maybe_blob) in sha256_hexes.iter().zip(blob_results.into_iter()) {
            if maybe_blob.is_some() {
                out.push(maybe_blob);
            } else {
                out.push(
                    self.get_raw_by_hash_entry(sha256_hex)?
                        .map(|entry| entry.bytes),
                );
            }
        }
        Ok(out)
    }

    pub fn put_vcir(&self, vcir: &ValidatedCaInstanceResult) -> StorageResult<()> {
        vcir.validate_internal()?;
        let vcir_cf = self.cf(CF_VCIR)?;
        let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?;
        let replay_meta = ManifestReplayMeta::from_vcir(vcir);
        replay_meta.validate_internal()?;
        let mut batch = WriteBatch::default();
        let key = vcir_key(&vcir.manifest_rsync_uri);
        let value = encode_cbor(vcir, "vcir")?;
        batch.put_cf(vcir_cf, key.as_bytes(), value);
        let replay_key = manifest_replay_meta_key(&replay_meta.manifest_rsync_uri);
        let replay_value = encode_cbor(&replay_meta, "manifest_replay_meta")?;
        batch.put_cf(replay_cf, replay_key.as_bytes(), replay_value);
        self.write_batch(batch)
    }

    pub fn replace_vcir_and_audit_rule_indexes(
        &self,
        previous: Option<&ValidatedCaInstanceResult>,
        vcir: &ValidatedCaInstanceResult,
    ) -> StorageResult<()> {
        vcir.validate_internal()?;
        let vcir_cf = self.cf(CF_VCIR)?;
        let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?;
        let audit_cf = self.cf(CF_AUDIT_RULE_INDEX)?;
        let mut batch = WriteBatch::default();

        let vcir_key = vcir_key(&vcir.manifest_rsync_uri);
        let vcir_value = encode_cbor(vcir, "vcir")?;
        batch.put_cf(vcir_cf, vcir_key.as_bytes(), vcir_value);
        let replay_meta = ManifestReplayMeta::from_vcir(vcir);
        replay_meta.validate_internal()?;
        let replay_key = manifest_replay_meta_key(&replay_meta.manifest_rsync_uri);
        let replay_value = encode_cbor(&replay_meta, "manifest_replay_meta")?;
        batch.put_cf(replay_cf, replay_key.as_bytes(), replay_value);

        if let Some(previous) = previous {
            for output in &previous.local_outputs {
                let Some(kind) = audit_rule_kind_for_output_type(output.output_type) else {
                    continue;
                };
                let key = audit_rule_key(kind, &output.rule_hash);
                batch.delete_cf(audit_cf, key.as_bytes());
            }
        }

        for output in &vcir.local_outputs {
            let Some(kind) = audit_rule_kind_for_output_type(output.output_type) else {
                continue;
            };
            let entry = AuditRuleIndexEntry {
                kind,
                rule_hash: output.rule_hash.clone(),
                manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
                source_object_uri: output.source_object_uri.clone(),
                source_object_hash: output.source_object_hash.clone(),
                output_id: output.output_id.clone(),
                item_effective_until: output.item_effective_until.clone(),
            };
            entry.validate_internal()?;
            let key = audit_rule_key(kind, &entry.rule_hash);
            let value = encode_cbor(&entry, "audit_rule_index")?;
            batch.put_cf(audit_cf, key.as_bytes(), value);
        }

        self.write_batch(batch)
    }

    pub fn get_vcir(
        &self,
        manifest_rsync_uri: &str,
    ) -> StorageResult<Option<ValidatedCaInstanceResult>> {
        let cf = self.cf(CF_VCIR)?;
        let key = vcir_key(manifest_rsync_uri);
        let Some(bytes) = self
            .db
            .get_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?
        else {
            return Ok(None);
        };
        let vcir = decode_cbor::<ValidatedCaInstanceResult>(&bytes, "vcir")?;
        vcir.validate_internal()?;
        Ok(Some(vcir))
    }

    pub fn get_manifest_replay_meta(
        &self,
        manifest_rsync_uri: &str,
    ) -> StorageResult<Option<ManifestReplayMeta>> {
        let cf = self.cf(CF_MANIFEST_REPLAY_META)?;
        let key = manifest_replay_meta_key(manifest_rsync_uri);
        let Some(bytes) = self
            .db
            .get_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?
        else {
            return Ok(None);
        };
        let meta = decode_cbor::<ManifestReplayMeta>(&bytes, "manifest_replay_meta")?;
        meta.validate_internal()?;
        Ok(Some(meta))
    }

    pub fn list_vcirs(&self) -> StorageResult<Vec<ValidatedCaInstanceResult>> {
        let cf = self.cf(CF_VCIR)?;
        let mode = IteratorMode::Start;
        let mut out = Vec::new();
        for res in self.db.iterator_cf(cf, mode) {
            let (_key, bytes) = res.map_err(|e| StorageError::RocksDb(e.to_string()))?;
            let vcir = decode_cbor::<ValidatedCaInstanceResult>(&bytes, "vcir")?;
            vcir.validate_internal()?;
            out.push(vcir);
        }
        Ok(out)
    }

    pub fn delete_vcir(&self, manifest_rsync_uri: &str) -> StorageResult<()> {
        let vcir_cf = self.cf(CF_VCIR)?;
        let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?;
        let mut batch = WriteBatch::default();
        let key = vcir_key(manifest_rsync_uri);
        batch.delete_cf(vcir_cf, key.as_bytes());
        let replay_key = manifest_replay_meta_key(manifest_rsync_uri);
        batch.delete_cf(replay_cf, replay_key.as_bytes());
        self.write_batch(batch)
    }

    pub fn put_audit_rule_index_entry(&self, entry: &AuditRuleIndexEntry) -> StorageResult<()> {
        entry.validate_internal()?;
        let cf = self.cf(CF_AUDIT_RULE_INDEX)?;
        let key = audit_rule_key(entry.kind, &entry.rule_hash);
        let value = encode_cbor(entry, "audit_rule_index")?;
        self.db
            .put_cf(cf, key.as_bytes(), value)
            .map_err(|e| StorageError::RocksDb(e.to_string()))?;
        Ok(())
    }

    pub fn get_audit_rule_index_entry(
        &self,
        kind: AuditRuleKind,
        rule_hash: &str,
    ) -> StorageResult<Option<AuditRuleIndexEntry>> {
        let cf = self.cf(CF_AUDIT_RULE_INDEX)?;
        let key = audit_rule_key(kind, rule_hash);
        let Some(bytes) = self
            .db
            .get_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?
        else {
            return Ok(None);
        };
        let entry = decode_cbor::<AuditRuleIndexEntry>(&bytes, "audit_rule_index")?;
        entry.validate_internal()?;
        Ok(Some(entry))
    }

    pub fn delete_audit_rule_index_entry(
        &self,
        kind: AuditRuleKind,
        rule_hash: &str,
    ) -> StorageResult<()> {
        let cf = self.cf(CF_AUDIT_RULE_INDEX)?;
        let key = audit_rule_key(kind, rule_hash);
        self.db
            .delete_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?;
        Ok(())
    }

    pub fn put_rrdp_source_record(&self, record: &RrdpSourceRecord) -> StorageResult<()> {
        record.validate_internal()?;
        let cf = self.cf(CF_RRDP_SOURCE)?;
        let key = rrdp_source_key(&record.notify_uri);
        let value = encode_cbor(record, "rrdp_source")?;
        self.db
            .put_cf(cf, key.as_bytes(), value)
            .map_err(|e| StorageError::RocksDb(e.to_string()))?;
        Ok(())
    }

    pub fn get_rrdp_source_record(
        &self,
        notify_uri: &str,
    ) -> StorageResult<Option<RrdpSourceRecord>> {
        let cf = self.cf(CF_RRDP_SOURCE)?;
        let key = rrdp_source_key(notify_uri);
        let Some(bytes) = self
            .db
            .get_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?
        else {
            return Ok(None);
        };
        let record = decode_cbor::<RrdpSourceRecord>(&bytes, "rrdp_source")?;
        record.validate_internal()?;
        Ok(Some(record))
    }

    pub fn put_rrdp_source_member_record(
        &self,
        record: &RrdpSourceMemberRecord,
    ) -> StorageResult<()> {
        record.validate_internal()?;
        let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?;
        let key = rrdp_source_member_key(&record.notify_uri, &record.rsync_uri);
        let value = encode_cbor(record, "rrdp_source_member")?;
        self.db
            .put_cf(cf, key.as_bytes(), value)
            .map_err(|e| StorageError::RocksDb(e.to_string()))?;
        Ok(())
    }

    pub fn get_rrdp_source_member_record(
        &self,
        notify_uri: &str,
        rsync_uri: &str,
    ) -> StorageResult<Option<RrdpSourceMemberRecord>> {
        let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?;
        let key = rrdp_source_member_key(notify_uri, rsync_uri);
        let Some(bytes) = self
            .db
            .get_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?
        else {
            return Ok(None);
        };
        let record = decode_cbor::<RrdpSourceMemberRecord>(&bytes, "rrdp_source_member")?;
        record.validate_internal()?;
        Ok(Some(record))
    }

    pub fn list_rrdp_source_member_records(
        &self,
        notify_uri: &str,
    ) -> StorageResult<Vec<RrdpSourceMemberRecord>> {
        let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?;
        let prefix = rrdp_source_member_prefix(notify_uri);
        let mode = IteratorMode::From(prefix.as_bytes(), Direction::Forward);
        self.db
            .iterator_cf(cf, mode)
            .take_while(|res| match res {
                Ok((key, _)) => key.starts_with(prefix.as_bytes()),
                Err(_) => false,
            })
            .map(|res| {
                let (_key, value) = res.map_err(|e| StorageError::RocksDb(e.to_string()))?;
                let record = decode_cbor::<RrdpSourceMemberRecord>(&value, "rrdp_source_member")?;
                record.validate_internal()?;
                Ok(record)
            })
            .collect()
    }

    pub fn list_current_rrdp_source_members(
        &self,
        notify_uri: &str,
    ) -> StorageResult<Vec<RrdpSourceMemberRecord>> {
        let mut records = self.list_rrdp_source_member_records(notify_uri)?;
        records.retain(|record| record.present);
        records.sort_by(|a, b| a.rsync_uri.cmp(&b.rsync_uri));
        Ok(records)
    }

    pub fn is_current_rrdp_source_member(
        &self,
        notify_uri: &str,
        rsync_uri: &str,
    ) -> StorageResult<bool> {
        Ok(matches!(
            self.get_rrdp_source_member_record(notify_uri, rsync_uri)?,
            Some(record) if record.present
        ))
    }

    pub fn load_current_object_bytes_by_uri(
        &self,
        rsync_uri: &str,
    ) -> StorageResult<Option<Vec<u8>>> {
        Ok(self
            .load_current_object_with_hash_by_uri(rsync_uri)?
            .map(|obj| obj.bytes))
    }

    pub fn load_current_object_with_hash_by_uri(
        &self,
        rsync_uri: &str,
    ) -> StorageResult<Option<CurrentObjectWithHash>> {
        let Some(view) = self.get_repository_view_entry(rsync_uri)? else {
            return Ok(None);
        };

        match view.state {
            RepositoryViewState::Withdrawn => Ok(None),
            RepositoryViewState::Present | RepositoryViewState::Replaced => {
                let hash = view
                    .current_hash
                    .as_deref()
                    .ok_or(StorageError::InvalidData {
                        entity: "repository_view",
                        detail: format!("current_hash missing for current object URI: {rsync_uri}"),
                    })?;
                let bytes = self
                    .get_blob_bytes(hash)?
                    .ok_or(StorageError::InvalidData {
                        entity: "repository_view",
                        detail: format!(
                            "blob bytes missing for current object URI: {rsync_uri} (hash={hash})"
                        ),
                    })?;
                let current_hash = decode_sha256_hex_32("repository_view.current_hash", hash)?;
                Ok(Some(CurrentObjectWithHash {
                    current_hash_hex: hash.to_ascii_lowercase(),
                    current_hash,
                    bytes,
                }))
            }
        }
    }

    pub fn put_rrdp_uri_owner_record(&self, record: &RrdpUriOwnerRecord) -> StorageResult<()> {
        record.validate_internal()?;
        let cf = self.cf(CF_RRDP_URI_OWNER)?;
        let key = rrdp_uri_owner_key(&record.rsync_uri);
        let value = encode_cbor(record, "rrdp_uri_owner")?;
        self.db
            .put_cf(cf, key.as_bytes(), value)
            .map_err(|e| StorageError::RocksDb(e.to_string()))?;
        Ok(())
    }

    pub fn get_rrdp_uri_owner_record(
        &self,
        rsync_uri: &str,
    ) -> StorageResult<Option<RrdpUriOwnerRecord>> {
        let cf = self.cf(CF_RRDP_URI_OWNER)?;
        let key = rrdp_uri_owner_key(rsync_uri);
        let Some(bytes) = self
            .db
            .get_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?
        else {
            return Ok(None);
        };
        let record = decode_cbor::<RrdpUriOwnerRecord>(&bytes, "rrdp_uri_owner")?;
        record.validate_internal()?;
        Ok(Some(record))
    }

    pub fn delete_rrdp_uri_owner_record(&self, rsync_uri: &str) -> StorageResult<()> {
        let cf = self.cf(CF_RRDP_URI_OWNER)?;
        let key = rrdp_uri_owner_key(rsync_uri);
        self.db
            .delete_cf(cf, key.as_bytes())
            .map_err(|e| StorageError::RocksDb(e.to_string()))?;
        Ok(())
    }

    #[allow(dead_code)]

    pub fn write_batch(&self, batch: WriteBatch) -> StorageResult<()> {
        self.db
            .write(batch)
            .map_err(|e| StorageError::RocksDb(e.to_string()))?;
        Ok(())
    }
}

fn repository_view_key(rsync_uri: &str) -> String {
    format!("{REPOSITORY_VIEW_KEY_PREFIX}{rsync_uri}")
}

fn repository_view_prefix(rsync_uri_prefix: &str) -> String {
    format!("{REPOSITORY_VIEW_KEY_PREFIX}{rsync_uri_prefix}")
}

fn raw_by_hash_key(sha256_hex: &str) -> String {
    format!("{RAW_BY_HASH_KEY_PREFIX}{sha256_hex}")
}

fn vcir_key(manifest_rsync_uri: &str) -> String {
    format!("{VCIR_KEY_PREFIX}{manifest_rsync_uri}")
}

fn manifest_replay_meta_key(manifest_rsync_uri: &str) -> String {
    format!("{MANIFEST_REPLAY_META_KEY_PREFIX}{manifest_rsync_uri}")
}

fn audit_rule_kind_for_output_type(output_type: VcirOutputType) -> Option<AuditRuleKind> {
    match output_type {
        VcirOutputType::Vrp => Some(AuditRuleKind::Roa),
        VcirOutputType::Aspa => Some(AuditRuleKind::Aspa),
        VcirOutputType::RouterKey => Some(AuditRuleKind::RouterKey),
    }
}

fn audit_rule_key(kind: AuditRuleKind, rule_hash: &str) -> String {
    format!("{}{rule_hash}", kind.key_prefix())
}

fn rrdp_source_key(notify_uri: &str) -> String {
    format!("{RRDP_SOURCE_KEY_PREFIX}{notify_uri}")
}

fn rrdp_source_member_key(notify_uri: &str, rsync_uri: &str) -> String {
    format!("{RRDP_SOURCE_MEMBER_KEY_PREFIX}{notify_uri}:{rsync_uri}")
}

fn rrdp_source_member_prefix(notify_uri: &str) -> String {
    format!("{RRDP_SOURCE_MEMBER_KEY_PREFIX}{notify_uri}:")
}

fn rrdp_uri_owner_key(rsync_uri: &str) -> String {
    format!("{RRDP_URI_OWNER_KEY_PREFIX}{rsync_uri}")
}

fn encode_cbor<T: Serialize>(value: &T, entity: &'static str) -> StorageResult<Vec<u8>> {
    serde_cbor::to_vec(value).map_err(|e| StorageError::Codec {
        entity,
        detail: e.to_string(),
    })
}

fn decode_cbor<T: DeserializeOwned>(bytes: &[u8], entity: &'static str) -> StorageResult<T> {
    serde_cbor::from_slice(bytes).map_err(|e| StorageError::Codec {
        entity,
        detail: e.to_string(),
    })
}

fn validate_non_empty(field: &'static str, value: &str) -> StorageResult<()> {
    if value.is_empty() {
        return Err(StorageError::InvalidData {
            entity: field,
            detail: "must not be empty".to_string(),
        });
    }
    Ok(())
}

fn validate_sha256_hex(field: &'static str, value: &str) -> StorageResult<()> {
    if value.len() != 64 || !value.as_bytes().iter().all(u8::is_ascii_hexdigit) {
        return Err(StorageError::InvalidData {
            entity: field,
            detail: "must be a 64-character lowercase or uppercase SHA-256 hex string".to_string(),
        });
    }
    Ok(())
}

fn decode_sha256_hex_32(field: &'static str, value: &str) -> StorageResult<[u8; 32]> {
    validate_sha256_hex(field, value)?;
    let mut out = [0u8; 32];
    hex::decode_to_slice(value, &mut out).map_err(|e| StorageError::InvalidData {
        entity: field,
        detail: format!("hex decode failed: {e}"),
    })?;
    Ok(out)
}

fn validate_manifest_number_be(field: &'static str, value: &[u8]) -> StorageResult<()> {
    if value.is_empty() {
        return Err(StorageError::InvalidData {
            entity: field,
            detail: "must not be empty".to_string(),
        });
    }
    if value.len() > 20 {
        return Err(StorageError::InvalidData {
            entity: field,
            detail: "must be at most 20 octets".to_string(),
        });
    }
    if value.len() > 1 && value[0] == 0 {
        return Err(StorageError::InvalidData {
            entity: field,
            detail: "must be minimal big-endian without leading zeros".to_string(),
        });
    }
    Ok(())
}

fn validate_sha256_digest_bytes(field: &'static str, value: &[u8]) -> StorageResult<()> {
    if value.len() != 32 {
        return Err(StorageError::InvalidData {
            entity: field,
            detail: format!("must be 32 bytes, got {}", value.len()),
        });
    }
    Ok(())
}

fn validate_fixed_len_bytes(
    field: &'static str,
    value: &[u8],
    expected_len: usize,
) -> StorageResult<()> {
    if value.len() != expected_len {
        return Err(StorageError::InvalidData {
            entity: field,
            detail: format!("must be {expected_len} bytes, got {}", value.len()),
        });
    }
    Ok(())
}

fn validate_sorted_unique_fixed_len_bytes(
    field: &'static str,
    values: &[Vec<u8>],
    expected_len: usize,
) -> StorageResult<()> {
    for value in values {
        validate_fixed_len_bytes(field, value, expected_len)?;
    }
    for window in values.windows(2) {
        if window[0] >= window[1] {
            return Err(StorageError::InvalidData {
                entity: field,
                detail: "must be strictly sorted and unique".to_string(),
            });
        }
    }
    Ok(())
}

fn validate_full_der_with_tag(
    field: &'static str,
    der: &[u8],
    expected_tag: Option<u8>,
) -> StorageResult<()> {
    let (tag, _value, rem) = der_take_tlv(der).map_err(|detail| StorageError::InvalidData {
        entity: field,
        detail,
    })?;
    if !rem.is_empty() {
        return Err(StorageError::InvalidData {
            entity: field,
            detail: "trailing bytes after DER object".to_string(),
        });
    }
    if let Some(expected_tag) = expected_tag {
        if tag != expected_tag {
            return Err(StorageError::InvalidData {
                entity: field,
                detail: format!("unexpected tag 0x{tag:02X}, expected 0x{expected_tag:02X}"),
            });
        }
    }
    Ok(())
}

fn parse_time(field: &'static str, value: &PackTime) -> StorageResult<time::OffsetDateTime> {
    value.parse().map_err(|detail| StorageError::InvalidData {
        entity: field,
        detail,
    })
}

fn enable_blobdb_current(opts: &mut Options) {
    #[allow(unused_mut)]
    let mut _enabled = false;

    #[allow(dead_code)]
    fn _set(opts: &mut Options) {
        opts.set_enable_blob_files(true);
        opts.set_min_blob_size(1024);
    }

    _set(opts);
}

#[derive(Clone, Debug)]
pub enum PackBytes {
    Eager(std::sync::Arc<[u8]>),
    LazyExternal {
        sha256_hex: String,
        store: std::sync::Arc<ExternalRawStoreDb>,
        cache: std::sync::Arc<std::sync::OnceLock<std::sync::Arc<[u8]>>>,
    },
    LazyRepoBytes {
        sha256_hex: String,
        store: std::sync::Arc<ExternalRepoBytesDb>,
        cache: std::sync::Arc<std::sync::OnceLock<std::sync::Arc<[u8]>>>,
    },
}

impl PackBytes {
    pub fn eager(bytes: Vec<u8>) -> Self {
        Self::Eager(std::sync::Arc::from(bytes))
    }

    pub fn lazy_external(sha256_hex: String, store: std::sync::Arc<ExternalRawStoreDb>) -> Self {
        Self::LazyExternal {
            sha256_hex,
            store,
            cache: std::sync::Arc::new(std::sync::OnceLock::new()),
        }
    }

    pub fn lazy_repo_bytes(sha256_hex: String, store: std::sync::Arc<ExternalRepoBytesDb>) -> Self {
        Self::LazyRepoBytes {
            sha256_hex,
            store,
            cache: std::sync::Arc::new(std::sync::OnceLock::new()),
        }
    }

    pub fn as_slice(&self) -> Result<&[u8], String> {
        match self {
            Self::Eager(bytes) => Ok(bytes.as_ref()),
            Self::LazyExternal {
                sha256_hex,
                store,
                cache,
            } => {
                if cache.get().is_none() {
                    let bytes = store
                        .get_blob_bytes(sha256_hex)
                        .map_err(|e| e.to_string())?
                        .ok_or_else(|| format!("missing raw blob for sha256={sha256_hex}"))?;
                    let _ = cache.set(std::sync::Arc::from(bytes));
                }
                let bytes = cache
                    .get()
                    .ok_or_else(|| format!("missing raw blob cache for sha256={sha256_hex}"))?;
                Ok(bytes.as_ref())
            }
            Self::LazyRepoBytes {
                sha256_hex,
                store,
                cache,
            } => {
                if cache.get().is_none() {
                    let bytes = store
                        .get_blob_bytes(sha256_hex)
                        .map_err(|e| e.to_string())?
                        .ok_or_else(|| format!("missing repo bytes for sha256={sha256_hex}"))?;
                    let _ = cache.set(std::sync::Arc::from(bytes));
                }
                let bytes = cache
                    .get()
                    .ok_or_else(|| format!("missing repo bytes cache for sha256={sha256_hex}"))?;
                Ok(bytes.as_ref())
            }
        }
    }

    pub fn to_vec(&self) -> Result<Vec<u8>, String> {
        Ok(self.as_slice()?.to_vec())
    }
}

impl PartialEq for PackBytes {
    fn eq(&self, other: &Self) -> bool {
        match (self.as_slice(), other.as_slice()) {
            (Ok(a), Ok(b)) => a == b,
            _ => false,
        }
    }
}

impl Eq for PackBytes {}

#[derive(Clone, Debug)]
pub struct PackFile {
    pub rsync_uri: String,
    pub bytes: PackBytes,
    pub sha256: [u8; 32],
}

impl PackFile {
    pub fn new(rsync_uri: impl Into<String>, bytes: PackBytes, sha256: [u8; 32]) -> Self {
        Self {
            rsync_uri: rsync_uri.into(),
            bytes,
            sha256,
        }
    }

    pub fn from_bytes_with_sha256(
        rsync_uri: impl Into<String>,
        bytes: Vec<u8>,
        sha256: [u8; 32],
    ) -> Self {
        Self::new(rsync_uri, PackBytes::eager(bytes), sha256)
    }

    pub fn from_lazy_external_raw_store(
        rsync_uri: impl Into<String>,
        sha256_hex: String,
        sha256: [u8; 32],
        store: std::sync::Arc<ExternalRawStoreDb>,
    ) -> Self {
        Self::new(
            rsync_uri,
            PackBytes::lazy_external(sha256_hex, store),
            sha256,
        )
    }

    pub fn from_lazy_repo_bytes(
        rsync_uri: impl Into<String>,
        sha256_hex: String,
        sha256: [u8; 32],
        store: std::sync::Arc<ExternalRepoBytesDb>,
    ) -> Self {
        Self::new(
            rsync_uri,
            PackBytes::lazy_repo_bytes(sha256_hex, store),
            sha256,
        )
    }

    pub fn from_bytes_compute_sha256(rsync_uri: impl Into<String>, bytes: Vec<u8>) -> Self {
        let sha256 = compute_sha256_32(&bytes);
        Self::new(rsync_uri, PackBytes::eager(bytes), sha256)
    }

    pub fn bytes(&self) -> Result<&[u8], String> {
        self.bytes.as_slice()
    }

    pub fn bytes_cloned(&self) -> Result<Vec<u8>, String> {
        self.bytes.to_vec()
    }

    pub fn compute_sha256(&self) -> Result<[u8; 32], String> {
        Ok(compute_sha256_32(self.bytes()?))
    }
}

impl PartialEq for PackFile {
    fn eq(&self, other: &Self) -> bool {
        self.rsync_uri == other.rsync_uri
            && self.sha256 == other.sha256
            && self.bytes == other.bytes
    }
}

impl Eq for PackFile {}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackTime {
    pub rfc3339_utc: String,
}

impl PackTime {
    pub fn from_utc_offset_datetime(t: time::OffsetDateTime) -> Self {
        use time::format_description::well_known::Rfc3339;
        let utc = t.to_offset(time::UtcOffset::UTC);
        let s = utc.format(&Rfc3339).expect("format RFC 3339 UTC time");
        Self { rfc3339_utc: s }
    }

    pub fn parse(&self) -> Result<time::OffsetDateTime, String> {
        use time::format_description::well_known::Rfc3339;
        time::OffsetDateTime::parse(&self.rfc3339_utc, &Rfc3339).map_err(|e| e.to_string())
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;

    fn pack_time(hour: i64) -> PackTime {
        PackTime::from_utc_offset_datetime(
            time::OffsetDateTime::UNIX_EPOCH + time::Duration::hours(hour),
        )
    }

    fn sha256_hex(input: &[u8]) -> String {
        hex::encode(compute_sha256_32(input))
    }

    #[test]
    fn parse_work_db_blob_mode_accepts_supported_values() {
        assert_eq!(default_work_db_blob_mode(), WorkDbBlobMode::Disabled);
        assert_eq!(
            parse_work_db_blob_mode("default"),
            Some(WorkDbBlobMode::Disabled)
        );
        assert_eq!(
            parse_work_db_blob_mode("current"),
            Some(WorkDbBlobMode::Current)
        );
        assert_eq!(
            parse_work_db_blob_mode("legacy"),
            Some(WorkDbBlobMode::Current)
        );
        assert_eq!(
            parse_work_db_blob_mode("disabled"),
            Some(WorkDbBlobMode::Disabled)
        );
        assert_eq!(
            parse_work_db_blob_mode("no-blob"),
            Some(WorkDbBlobMode::Disabled)
        );
        assert_eq!(parse_work_db_blob_mode("lz4"), Some(WorkDbBlobMode::Lz4));
        assert_eq!(
            parse_work_db_blob_mode("blob-lz4"),
            Some(WorkDbBlobMode::Lz4)
        );
        assert_eq!(parse_work_db_blob_mode("unexpected"), None);
    }

    fn sample_repository_view_entry(rsync_uri: &str, bytes: &[u8]) -> RepositoryViewEntry {
        RepositoryViewEntry {
            rsync_uri: rsync_uri.to_string(),
            current_hash: Some(sha256_hex(bytes)),
            repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
            object_type: Some("cer".to_string()),
            state: RepositoryViewState::Present,
        }
    }

    fn sample_raw_by_hash_entry(bytes: Vec<u8>) -> RawByHashEntry {
        RawByHashEntry {
            sha256_hex: sha256_hex(&bytes),
            bytes,
            origin_uris: vec!["rsync://example.test/repo/object.cer".to_string()],
            object_type: Some("cer".to_string()),
            encoding: Some("der".to_string()),
        }
    }

    fn sample_ccr_manifest_projection(
        manifest_rsync_uri: &str,
        manifest_this_update: PackTime,
        subordinate_skis: Vec<Vec<u8>>,
    ) -> VcirCcrManifestProjection {
        VcirCcrManifestProjection {
            manifest_rsync_uri: manifest_rsync_uri.to_string(),
            manifest_sha256: vec![0x11; 32],
            manifest_size: 4096,
            manifest_ee_aki: vec![0x22; 20],
            manifest_number_be: vec![3],
            manifest_this_update,
            manifest_sia_locations_der: vec![vec![
                0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05,
                b'r', b's', b'y', b'n', b'c',
            ]],
            subordinate_skis,
        }
    }

    fn sample_vcir(manifest_rsync_uri: &str) -> ValidatedCaInstanceResult {
        let roa_bytes = b"roa-object".to_vec();
        let ee_bytes = b"ee-cert".to_vec();
        let child_bytes = b"child-cert".to_vec();
        let child_ski = "1234567890abcdef1234567890abcdef12345678".to_string();
        ValidatedCaInstanceResult {
            manifest_rsync_uri: manifest_rsync_uri.to_string(),
            parent_manifest_rsync_uri: Some(
                "rsync://example.test/repo/parent/parent.mft".to_string(),
            ),
            tal_id: "apnic".to_string(),
            ca_subject_name: "CN=Example CA".to_string(),
            ca_ski: "00112233445566778899aabbccddeeff00112233".to_string(),
            issuer_ski: "ffeeddccbbaa99887766554433221100ffeeddcc".to_string(),
            last_successful_validation_time: pack_time(0),
            current_manifest_rsync_uri: manifest_rsync_uri.to_string(),
            current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(),
            validated_manifest_meta: ValidatedManifestMeta {
                validated_manifest_number: vec![3],
                validated_manifest_this_update: pack_time(0),
                validated_manifest_next_update: pack_time(24),
            },
            ccr_manifest_projection: sample_ccr_manifest_projection(
                manifest_rsync_uri,
                pack_time(0),
                vec![hex::decode(&child_ski).expect("decode child ski")],
            ),
            instance_gate: VcirInstanceGate {
                manifest_next_update: pack_time(24),
                current_crl_next_update: pack_time(12),
                self_ca_not_after: pack_time(48),
                instance_effective_until: pack_time(12),
            },
            child_entries: vec![VcirChildEntry {
                child_manifest_rsync_uri: "rsync://example.test/repo/child/child.mft".to_string(),
                child_cert_rsync_uri: "rsync://example.test/repo/child/child.cer".to_string(),
                child_cert_hash: sha256_hex(&child_bytes),
                child_ski,
                child_rsync_base_uri: "rsync://example.test/repo/child/".to_string(),
                child_publication_point_rsync_uri: "rsync://example.test/repo/child/".to_string(),
                child_rrdp_notification_uri: Some(
                    "https://example.test/child-notify.xml".to_string(),
                ),
                child_effective_ip_resources: None,
                child_effective_as_resources: None,
                accepted_at_validation_time: pack_time(0),
            }],
            local_outputs: vec![
                VcirLocalOutput {
                    output_id: "vrp-1".to_string(),
                    output_type: VcirOutputType::Vrp,
                    item_effective_until: pack_time(12),
                    source_object_uri: "rsync://example.test/repo/object.roa".to_string(),
                    source_object_type: "roa".to_string(),
                    source_object_hash: sha256_hex(&roa_bytes),
                    source_ee_cert_hash: sha256_hex(&ee_bytes),
                    payload_json: r#"{"asn":64496,"prefix":"203.0.113.0/24"}"#.to_string(),
                    rule_hash: sha256_hex(b"vrp-rule-1"),
                    validation_path_hint: vec![manifest_rsync_uri.to_string()],
                },
                VcirLocalOutput {
                    output_id: "aspa-1".to_string(),
                    output_type: VcirOutputType::Aspa,
                    item_effective_until: pack_time(10),
                    source_object_uri: "rsync://example.test/repo/object.asa".to_string(),
                    source_object_type: "aspa".to_string(),
                    source_object_hash: sha256_hex(b"aspa-object"),
                    source_ee_cert_hash: sha256_hex(b"aspa-ee-cert"),
                    payload_json: r#"{"customer_as":64496,"providers":[64497]}"#.to_string(),
                    rule_hash: sha256_hex(b"aspa-rule-1"),
                    validation_path_hint: vec![manifest_rsync_uri.to_string()],
                },
            ],
            related_artifacts: vec![
                VcirRelatedArtifact {
                    artifact_role: VcirArtifactRole::Manifest,
                    artifact_kind: VcirArtifactKind::Mft,
                    uri: Some(manifest_rsync_uri.to_string()),
                    sha256: sha256_hex(b"manifest-object"),
                    object_type: Some("mft".to_string()),
                    validation_status: VcirArtifactValidationStatus::Accepted,
                },
                VcirRelatedArtifact {
                    artifact_role: VcirArtifactRole::CurrentCrl,
                    artifact_kind: VcirArtifactKind::Crl,
                    uri: Some("rsync://example.test/repo/current.crl".to_string()),
                    sha256: sha256_hex(b"current-crl"),
                    object_type: Some("crl".to_string()),
                    validation_status: VcirArtifactValidationStatus::Accepted,
                },
            ],
            summary: VcirSummary {
                local_vrp_count: 1,
                local_aspa_count: 1,
                local_router_key_count: 0,
                child_count: 1,
                accepted_object_count: 4,
                rejected_object_count: 0,
            },
            audit_summary: VcirAuditSummary {
                failed_fetch_eligible: true,
                last_failed_fetch_reason: None,
                warning_count: 0,
                audit_flags: vec!["validated-fresh".to_string()],
            },
        }
    }

    #[test]
    fn vcir_ccr_manifest_projection_validate_accepts_valid_projection() {
        let projection = sample_ccr_manifest_projection(
            "rsync://example.test/repo/current.mft",
            pack_time(0),
            vec![vec![0x33; 20], vec![0x44; 20]],
        );
        projection.validate_internal().expect("valid projection");
    }

    #[test]
    fn vcir_ccr_manifest_projection_validate_rejects_invalid_fields() {
        let mut bad_hash = sample_ccr_manifest_projection(
            "rsync://example.test/repo/current.mft",
            pack_time(0),
            vec![vec![0x33; 20]],
        );
        bad_hash.manifest_sha256 = vec![0x11; 31];
        assert!(matches!(
            bad_hash.validate_internal(),
            Err(StorageError::InvalidData { .. })
        ));

        let mut bad_locations = sample_ccr_manifest_projection(
            "rsync://example.test/repo/current.mft",
            pack_time(0),
            vec![vec![0x33; 20]],
        );
        bad_locations.manifest_sia_locations_der = vec![vec![0x04, 0x00]];
        assert!(matches!(
            bad_locations.validate_internal(),
            Err(StorageError::InvalidData { .. })
        ));

        let bad_subordinates = sample_ccr_manifest_projection(
            "rsync://example.test/repo/current.mft",
            pack_time(0),
            vec![vec![0x44; 20], vec![0x33; 20]],
        );
        assert!(matches!(
            bad_subordinates.validate_internal(),
            Err(StorageError::InvalidData { .. })
        ));
    }

    fn sample_audit_rule_entry(kind: AuditRuleKind) -> AuditRuleIndexEntry {
        AuditRuleIndexEntry {
            kind,
            rule_hash: sha256_hex(match kind {
                AuditRuleKind::Roa => b"roa-index-rule",
                AuditRuleKind::Aspa => b"aspa-index-rule",
                AuditRuleKind::RouterKey => b"router-key-index-rule",
            }),
            manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
            source_object_uri: match kind {
                AuditRuleKind::Roa => "rsync://example.test/repo/object.roa".to_string(),
                AuditRuleKind::Aspa => "rsync://example.test/repo/object.asa".to_string(),
                AuditRuleKind::RouterKey => "rsync://example.test/repo/router.cer".to_string(),
            },
            source_object_hash: sha256_hex(match kind {
                AuditRuleKind::Roa => b"roa-object",
                AuditRuleKind::Aspa => b"aspa-object",
                AuditRuleKind::RouterKey => b"router-key-object",
            }),
            output_id: match kind {
                AuditRuleKind::Roa => "vrp-1".to_string(),
                AuditRuleKind::Aspa => "aspa-1".to_string(),
                AuditRuleKind::RouterKey => "router-key-1".to_string(),
            },
            item_effective_until: pack_time(12),
        }
    }

    fn sample_rrdp_source_record(notify_uri: &str) -> RrdpSourceRecord {
        RrdpSourceRecord {
            notify_uri: notify_uri.to_string(),
            last_session_id: Some("session-1".to_string()),
            last_serial: Some(42),
            first_seen_at: pack_time(0),
            last_seen_at: pack_time(1),
            last_sync_at: Some(pack_time(1)),
            sync_state: RrdpSourceSyncState::DeltaReady,
            last_snapshot_uri: Some("https://rrdp.example.test/snapshot.xml".to_string()),
            last_snapshot_hash: Some(sha256_hex(b"snapshot-bytes")),
            last_error: None,
        }
    }

    fn sample_rrdp_source_member_record(
        notify_uri: &str,
        rsync_uri: &str,
        serial: u64,
    ) -> RrdpSourceMemberRecord {
        RrdpSourceMemberRecord {
            notify_uri: notify_uri.to_string(),
            rsync_uri: rsync_uri.to_string(),
            current_hash: Some(sha256_hex(rsync_uri.as_bytes())),
            object_type: Some("cer".to_string()),
            present: true,
            last_confirmed_session_id: "session-1".to_string(),
            last_confirmed_serial: serial,
            last_changed_at: pack_time(serial as i64),
        }
    }

    fn sample_rrdp_uri_owner_record(notify_uri: &str, rsync_uri: &str) -> RrdpUriOwnerRecord {
        RrdpUriOwnerRecord {
            rsync_uri: rsync_uri.to_string(),
            notify_uri: notify_uri.to_string(),
            current_hash: Some(sha256_hex(rsync_uri.as_bytes())),
            last_confirmed_session_id: "session-1".to_string(),
            last_confirmed_serial: 7,
            last_changed_at: pack_time(7),
            owner_state: RrdpUriOwnerState::Active,
        }
    }

    #[test]
    fn repository_view_and_raw_by_hash_roundtrip() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let entry1 = sample_repository_view_entry("rsync://example.test/repo/a.cer", b"object-a");
        let entry2 =
            sample_repository_view_entry("rsync://example.test/repo/sub/b.roa", b"object-b");
        store
            .put_repository_view_entry(&entry1)
            .expect("put repository view entry1");
        store
            .put_repository_view_entry(&entry2)
            .expect("put repository view entry2");

        let got1 = store
            .get_repository_view_entry(&entry1.rsync_uri)
            .expect("get repository view entry1")
            .expect("entry1 exists");
        assert_eq!(got1, entry1);

        let got_prefix = store
            .list_repository_view_entries_with_prefix("rsync://example.test/repo/sub/")
            .expect("list repository view prefix");
        assert_eq!(got_prefix, vec![entry2.clone()]);

        store
            .delete_repository_view_entry(&entry1.rsync_uri)
            .expect("delete repository view entry1");
        assert!(
            store
                .get_repository_view_entry(&entry1.rsync_uri)
                .expect("get deleted repository view entry1")
                .is_none()
        );

        let raw = sample_raw_by_hash_entry(b"raw-der-object".to_vec());
        store
            .put_raw_by_hash_entry(&raw)
            .expect("put raw_by_hash entry");
        let got_raw = store
            .get_raw_by_hash_entry(&raw.sha256_hex)
            .expect("get raw_by_hash entry")
            .expect("raw entry exists");
        assert_eq!(got_raw, raw);
    }

    #[test]
    fn raw_by_hash_routes_to_external_raw_store_when_configured() {
        let td = tempfile::tempdir().expect("tempdir");
        let main_db = td.path().join("main-db");
        let raw_db = td.path().join("raw-store.db");

        let raw = sample_raw_by_hash_entry(b"external-raw".to_vec());
        {
            let store =
                RocksStore::open_with_external_raw_store(&main_db, &raw_db).expect("open store");
            store.put_raw_by_hash_entry(&raw).expect("put external raw");

            let got = store
                .get_raw_by_hash_entry(&raw.sha256_hex)
                .expect("get external raw")
                .expect("raw exists");
            assert_eq!(got, raw);
        }

        let main_store = RocksStore::open(&main_db).expect("open main only");
        assert!(
            main_store
                .get_raw_by_hash_entry(&raw.sha256_hex)
                .expect("read main store")
                .is_none(),
            "main db should not contain raw entry when external raw store is configured"
        );
    }

    #[test]
    fn put_blob_bytes_batch_uses_internal_blob_cf_without_raw_entry() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");
        let bytes = b"internal-blob-only".to_vec();
        let hash = sha256_hex(&bytes);

        store
            .put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
            .expect("put blob bytes");

        assert_eq!(
            store.get_blob_bytes(&hash).expect("get blob bytes"),
            Some(bytes.clone())
        );
        assert!(
            store
                .get_raw_by_hash_entry(&hash)
                .expect("get raw entry")
                .is_none()
        );
    }

    #[test]
    fn put_blob_bytes_batch_routes_to_external_raw_store_without_raw_entry() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open_with_external_raw_store(
            &td.path().join("main-db"),
            &td.path().join("raw-store.db"),
        )
        .expect("open store");
        let bytes = b"external-blob-only".to_vec();
        let hash = sha256_hex(&bytes);

        store
            .put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
            .expect("put external blob bytes");

        assert_eq!(store.get_blob_bytes(&hash).unwrap(), Some(bytes));
        assert!(store.get_raw_by_hash_entry(&hash).unwrap().is_none());
    }

    #[test]
    fn repo_bytes_db_is_physically_separate_from_external_raw_store() {
        let td = tempfile::tempdir().expect("tempdir");
        let main_db = td.path().join("main-db");
        let raw_db = td.path().join("raw-store.db");
        let repo_bytes_db = td.path().join("repo-bytes.db");
        let store =
            RocksStore::open_with_external_stores(&main_db, Some(&raw_db), Some(&repo_bytes_db))
                .expect("open store");
        let repo_bytes = b"repo-object".to_vec();
        let repo_hash = sha256_hex(&repo_bytes);
        let raw = sample_raw_by_hash_entry(b"raw-evidence".to_vec());

        store
            .put_blob_bytes_batch(&[(repo_hash.clone(), repo_bytes.clone())])
            .expect("put repo bytes");
        store.put_raw_by_hash_entry(&raw).expect("put raw evidence");

        assert_eq!(store.get_blob_bytes(&repo_hash).unwrap(), Some(repo_bytes));
        assert_eq!(
            store.get_raw_by_hash_entry(&raw.sha256_hex).unwrap(),
            Some(raw.clone())
        );
        drop(store);

        let raw_only =
            RocksStore::open_with_external_raw_store(&td.path().join("raw-reader"), &raw_db)
                .expect("open raw only");
        assert!(
            raw_only.get_blob_bytes(&repo_hash).unwrap().is_none(),
            "repo object bytes must not be written into raw-store.db"
        );

        let repo_only = RocksStore::open_with_external_repo_bytes(
            &td.path().join("repo-reader"),
            &repo_bytes_db,
        )
        .expect("open repo bytes only");
        assert_eq!(
            repo_only.get_blob_bytes(&repo_hash).unwrap(),
            Some(b"repo-object".to_vec())
        );
        assert!(
            repo_only.get_blob_bytes(&raw.sha256_hex).unwrap().is_none(),
            "raw evidence bytes must not be written into repo-bytes.db"
        );
    }

    #[test]
    fn put_blob_bytes_batch_accepts_empty_batch_with_external_raw_store() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open_with_external_raw_store(
            &td.path().join("main-db"),
            &td.path().join("raw-store.db"),
        )
        .expect("open store");

        store
            .put_blob_bytes_batch(&[])
            .expect("empty external blob batch should be a no-op");
    }

    #[test]
    fn get_blob_bytes_internal_falls_back_to_raw_entry_when_blob_missing() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");
        let raw = sample_raw_by_hash_entry(b"raw-fallback".to_vec());

        store.put_raw_by_hash_entry(&raw).expect("put raw entry");

        assert_eq!(
            store
                .get_blob_bytes(&raw.sha256_hex)
                .expect("get blob bytes via raw fallback"),
            Some(raw.bytes.clone())
        );
    }

    #[test]
    fn get_blob_bytes_batch_internal_prefers_blob_cf_and_falls_back_to_raw_entry() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let blob_bytes = b"blob-cf-object".to_vec();
        let blob_hash = sha256_hex(&blob_bytes);
        store
            .put_blob_bytes_batch(&[(blob_hash.clone(), blob_bytes.clone())])
            .expect("put blob bytes");

        let raw = sample_raw_by_hash_entry(b"raw-fallback-batch".to_vec());
        store.put_raw_by_hash_entry(&raw).expect("put raw fallback");

        let batch = store
            .get_blob_bytes_batch(&[blob_hash.clone(), raw.sha256_hex.clone(), "00".repeat(32)])
            .expect("get blob bytes batch");
        assert_eq!(batch, vec![Some(blob_bytes), Some(raw.bytes.clone()), None]);
    }

    #[test]
    fn get_blob_bytes_batch_routes_to_external_raw_store_without_raw_entry() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open_with_external_raw_store(
            &td.path().join("main-db"),
            &td.path().join("raw-store.db"),
        )
        .expect("open store");
        let bytes = b"external-batch-blob".to_vec();
        let hash = sha256_hex(&bytes);

        store
            .put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
            .expect("put external blob bytes");

        assert_eq!(
            store
                .get_blob_bytes_batch(&[hash, "00".repeat(32)])
                .expect("get external blob batch"),
            vec![Some(bytes), None]
        );
    }

    #[test]
    fn get_blob_bytes_rejects_invalid_hash_for_internal_store() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let err = store
            .get_blob_bytes("not-a-valid-hash")
            .expect_err("invalid hash must fail");
        assert!(matches!(err, StorageError::InvalidData { .. }));
    }

    #[test]
    fn get_blob_bytes_batch_rejects_invalid_hash_for_internal_store() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let err = store
            .get_blob_bytes_batch(&["not-a-valid-hash".to_string()])
            .expect_err("invalid hash must fail");
        assert!(matches!(err, StorageError::InvalidData { .. }));
    }

    #[test]
    fn get_blob_bytes_batch_returns_empty_for_empty_request_internal() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        assert!(
            store
                .get_blob_bytes_batch(&[])
                .expect("empty blob batch request")
                .is_empty()
        );
    }

    #[test]
    fn put_blob_bytes_batch_accepts_empty_batch() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        store
            .put_blob_bytes_batch(&[])
            .expect("empty blob batch should be a no-op");
    }

    #[test]
    fn put_blob_bytes_batch_rejects_empty_bytes() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let err = store
            .put_blob_bytes_batch(&[(sha256_hex(b"valid"), Vec::new())])
            .expect_err("empty bytes must fail");
        assert!(matches!(err, StorageError::InvalidData { .. }));
    }

    #[test]
    fn delete_raw_by_hash_entry_internal_preserves_blob_bytes() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");
        let bytes = b"blob-persists-after-raw-delete".to_vec();
        let hash = sha256_hex(&bytes);
        let raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone());

        store
            .put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
            .expect("put blob bytes");
        store.put_raw_by_hash_entry(&raw).expect("put raw entry");

        store
            .delete_raw_by_hash_entry(&hash)
            .expect("delete raw entry only");

        assert!(store.get_raw_by_hash_entry(&hash).unwrap().is_none());
        assert_eq!(store.get_blob_bytes(&hash).unwrap(), Some(bytes));
    }

    #[test]
    fn delete_raw_by_hash_entry_rejects_invalid_hash() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let err = store
            .delete_raw_by_hash_entry("not-a-valid-hash")
            .expect_err("invalid hash must fail");
        assert!(matches!(err, StorageError::InvalidData { .. }));
    }

    #[test]
    fn delete_raw_by_hash_entry_routes_to_external_raw_store() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open_with_external_raw_store(
            &td.path().join("main-db"),
            &td.path().join("raw-store.db"),
        )
        .expect("open store");
        let raw = sample_raw_by_hash_entry(b"external-delete".to_vec());

        store.put_raw_by_hash_entry(&raw).expect("put raw entry");
        store
            .delete_raw_by_hash_entry(&raw.sha256_hex)
            .expect("delete external raw entry");

        assert!(
            store
                .get_raw_by_hash_entry(&raw.sha256_hex)
                .unwrap()
                .is_none()
        );
        assert!(store.get_blob_bytes(&raw.sha256_hex).unwrap().is_none());
    }

    #[test]
    fn repository_view_and_raw_by_hash_validation_errors_are_reported() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let invalid_view = RepositoryViewEntry {
            rsync_uri: "rsync://example.test/repo/withdrawn.cer".to_string(),
            current_hash: None,
            repository_source: None,
            object_type: None,
            state: RepositoryViewState::Present,
        };
        let err = store
            .put_repository_view_entry(&invalid_view)
            .expect_err("missing current_hash must fail");
        assert!(err.to_string().contains("current_hash is required"));

        let invalid_raw = RawByHashEntry {
            sha256_hex: sha256_hex(b"expected"),
            bytes: b"actual".to_vec(),
            origin_uris: vec!["rsync://example.test/repo/object.cer".to_string()],
            object_type: None,
            encoding: None,
        };
        let err = store
            .put_raw_by_hash_entry(&invalid_raw)
            .expect_err("mismatched raw_by_hash entry must fail");
        assert!(err.to_string().contains("does not match bytes"));
    }

    #[test]
    fn vcir_roundtrip_and_validation_failures_are_reported() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let vcir = sample_vcir("rsync://example.test/repo/current.mft");
        store.put_vcir(&vcir).expect("put vcir");
        let got = store
            .get_vcir(&vcir.manifest_rsync_uri)
            .expect("get vcir")
            .expect("vcir exists");
        assert_eq!(got, vcir);
        let replay_meta = store
            .get_manifest_replay_meta(&vcir.manifest_rsync_uri)
            .expect("get manifest replay meta")
            .expect("manifest replay meta exists");
        assert_eq!(
            replay_meta,
            ManifestReplayMeta {
                manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
                manifest_number_be: vcir
                    .validated_manifest_meta
                    .validated_manifest_number
                    .clone(),
                manifest_this_update: vcir
                    .validated_manifest_meta
                    .validated_manifest_this_update
                    .clone(),
                manifest_sha256: vcir.ccr_manifest_projection.manifest_sha256.clone(),
                updated_at_validation_time: vcir.last_successful_validation_time.clone(),
            }
        );

        let mut invalid = sample_vcir("rsync://example.test/repo/invalid.mft");
        invalid.summary.local_vrp_count = 9;
        let err = store
            .put_vcir(&invalid)
            .expect_err("invalid vcir must fail");
        assert!(err.to_string().contains("local_vrp_count=9"));

        let mut invalid = sample_vcir("rsync://example.test/repo/invalid-2.mft");
        invalid.instance_gate.instance_effective_until = pack_time(11);
        let err = store
            .put_vcir(&invalid)
            .expect_err("invalid instance gate must fail");
        assert!(err.to_string().contains("instance_effective_until"));

        store
            .delete_vcir(&vcir.manifest_rsync_uri)
            .expect("delete vcir");
        assert!(
            store
                .get_vcir(&vcir.manifest_rsync_uri)
                .expect("get deleted vcir")
                .is_none()
        );
        assert!(
            store
                .get_manifest_replay_meta(&vcir.manifest_rsync_uri)
                .expect("get deleted manifest replay meta")
                .is_none()
        );
    }

    #[test]
    fn manifest_replay_meta_validation_reports_invalid_fields() {
        let mut meta = ManifestReplayMeta {
            manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
            manifest_number_be: vec![3],
            manifest_this_update: pack_time(0),
            manifest_sha256: vec![0x11; 32],
            updated_at_validation_time: pack_time(1),
        };
        meta.validate_internal().expect("valid replay meta");

        meta.manifest_sha256 = vec![0x11; 31];
        let err = meta
            .validate_internal()
            .expect_err("short manifest sha must fail");
        assert!(err.to_string().contains("must be 32 bytes"));

        meta.manifest_sha256 = vec![0x11; 32];
        meta.manifest_number_be = vec![0, 3];
        let err = meta
            .validate_internal()
            .expect_err("non-minimal manifest number must fail");
        assert!(err.to_string().contains("minimal big-endian"));
    }

    #[test]
    fn list_vcirs_returns_all_entries() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let vcir1 = sample_vcir("rsync://example.test/repo/a.mft");
        let vcir2 = sample_vcir("rsync://example.test/repo/b.mft");
        store.put_vcir(&vcir1).expect("put vcir1");
        store.put_vcir(&vcir2).expect("put vcir2");

        let mut got = store.list_vcirs().expect("list vcirs");
        got.sort_by(|a, b| a.manifest_rsync_uri.cmp(&b.manifest_rsync_uri));
        assert_eq!(got, vec![vcir1, vcir2]);
    }

    #[test]
    fn audit_rule_index_roundtrip_for_roa_aspa_and_router_key() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let roa = sample_audit_rule_entry(AuditRuleKind::Roa);
        let aspa = sample_audit_rule_entry(AuditRuleKind::Aspa);
        let router_key = sample_audit_rule_entry(AuditRuleKind::RouterKey);
        store
            .put_audit_rule_index_entry(&roa)
            .expect("put roa audit rule entry");
        store
            .put_audit_rule_index_entry(&aspa)
            .expect("put aspa audit rule entry");
        store
            .put_audit_rule_index_entry(&router_key)
            .expect("put router key audit rule entry");

        let got_roa = store
            .get_audit_rule_index_entry(AuditRuleKind::Roa, &roa.rule_hash)
            .expect("get roa audit rule entry")
            .expect("roa entry exists");
        let got_aspa = store
            .get_audit_rule_index_entry(AuditRuleKind::Aspa, &aspa.rule_hash)
            .expect("get aspa audit rule entry")
            .expect("aspa entry exists");
        let got_router_key = store
            .get_audit_rule_index_entry(AuditRuleKind::RouterKey, &router_key.rule_hash)
            .expect("get router key audit rule entry")
            .expect("router key entry exists");
        assert_eq!(got_roa, roa);
        assert_eq!(got_aspa, aspa);
        assert_eq!(got_router_key, router_key);

        store
            .delete_audit_rule_index_entry(AuditRuleKind::Roa, &roa.rule_hash)
            .expect("delete roa audit rule entry");
        assert!(
            store
                .get_audit_rule_index_entry(AuditRuleKind::Roa, &roa.rule_hash)
                .expect("get deleted roa audit rule entry")
                .is_none()
        );

        let mut invalid = sample_audit_rule_entry(AuditRuleKind::Roa);
        invalid.rule_hash = "bad".to_string();
        let err = store
            .put_audit_rule_index_entry(&invalid)
            .expect_err("invalid audit rule hash must fail");
        assert!(err.to_string().contains("64-character"));
    }

    #[test]
    fn replace_vcir_and_audit_rule_indexes_replaces_previous_entries_in_one_step() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let mut previous = sample_vcir("rsync://example.test/repo/current.mft");
        previous.local_outputs = vec![VcirLocalOutput {
            output_id: "old-output".to_string(),
            output_type: VcirOutputType::Vrp,
            item_effective_until: pack_time(10),
            source_object_uri: "rsync://example.test/repo/old.roa".to_string(),
            source_object_type: "roa".to_string(),
            source_object_hash: sha256_hex(b"old-roa"),
            source_ee_cert_hash: sha256_hex(b"old-ee"),
            payload_json: "{}".to_string(),
            rule_hash: sha256_hex(b"old-rule"),
            validation_path_hint: vec![previous.manifest_rsync_uri.clone()],
        }];
        previous.summary.local_vrp_count = 1;
        previous.summary.local_aspa_count = 0;
        previous.summary.local_router_key_count = 0;
        store
            .replace_vcir_and_audit_rule_indexes(None, &previous)
            .expect("store previous vcir");
        assert!(
            store
                .get_audit_rule_index_entry(
                    AuditRuleKind::Roa,
                    &previous.local_outputs[0].rule_hash
                )
                .expect("get old audit entry")
                .is_some()
        );

        let mut current = sample_vcir("rsync://example.test/repo/current.mft");
        current.local_outputs = vec![VcirLocalOutput {
            output_id: "new-output".to_string(),
            output_type: VcirOutputType::Aspa,
            item_effective_until: pack_time(11),
            source_object_uri: "rsync://example.test/repo/new.asa".to_string(),
            source_object_type: "aspa".to_string(),
            source_object_hash: sha256_hex(b"new-aspa"),
            source_ee_cert_hash: sha256_hex(b"new-ee"),
            payload_json: "{}".to_string(),
            rule_hash: sha256_hex(b"new-rule"),
            validation_path_hint: vec![current.manifest_rsync_uri.clone()],
        }];
        current.summary.local_vrp_count = 0;
        current.summary.local_aspa_count = 1;
        store
            .replace_vcir_and_audit_rule_indexes(Some(&previous), &current)
            .expect("replace vcir and audit indexes");

        let got = store
            .get_vcir(&current.manifest_rsync_uri)
            .expect("get replaced vcir")
            .expect("vcir exists");
        assert_eq!(got, current);
        let replay_meta = store
            .get_manifest_replay_meta(&current.manifest_rsync_uri)
            .expect("get replaced replay meta")
            .expect("replay meta exists");
        assert_eq!(
            replay_meta.manifest_number_be,
            current.validated_manifest_meta.validated_manifest_number
        );
        assert_eq!(
            replay_meta.manifest_sha256,
            current.ccr_manifest_projection.manifest_sha256
        );
        assert!(
            store
                .get_audit_rule_index_entry(
                    AuditRuleKind::Roa,
                    &previous.local_outputs[0].rule_hash
                )
                .expect("get deleted old audit entry")
                .is_none()
        );
        assert!(
            store
                .get_audit_rule_index_entry(
                    AuditRuleKind::Aspa,
                    &current.local_outputs[0].rule_hash
                )
                .expect("get new audit entry")
                .is_some()
        );
    }

    #[test]
    fn storage_helpers_cover_optional_validation_paths() {
        let withdrawn = RepositoryViewEntry {
            rsync_uri: "rsync://example.test/repo/withdrawn.cer".to_string(),
            current_hash: Some(sha256_hex(b"withdrawn")),
            repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
            object_type: Some("cer".to_string()),
            state: RepositoryViewState::Withdrawn,
        };
        withdrawn
            .validate_internal()
            .expect("withdrawn repository view validates");

        let raw = RawByHashEntry::from_bytes(sha256_hex(b"helper-bytes"), b"helper-bytes".to_vec());
        raw.validate_internal()
            .expect("raw_by_hash helper validates");

        let empty_raw = RawByHashEntry {
            sha256_hex: sha256_hex(b"x"),
            bytes: Vec::new(),
            origin_uris: Vec::new(),
            object_type: None,
            encoding: None,
        };
        let err = empty_raw
            .validate_internal()
            .expect_err("empty raw bytes must fail");
        assert!(err.to_string().contains("bytes must not be empty"));

        let duplicate_origin_raw = RawByHashEntry {
            sha256_hex: sha256_hex(b"dup-origin"),
            bytes: b"dup-origin".to_vec(),
            origin_uris: vec![
                "rsync://example.test/repo/object.cer".to_string(),
                "rsync://example.test/repo/object.cer".to_string(),
            ],
            object_type: Some("cer".to_string()),
            encoding: Some("der".to_string()),
        };
        let err = duplicate_origin_raw
            .validate_internal()
            .expect_err("duplicate origin URI must fail");
        assert!(err.to_string().contains("duplicate origin URI"));
    }

    #[test]
    fn rrdp_source_optional_fields_and_owner_without_hash_validate() {
        let source = RrdpSourceRecord {
            notify_uri: "https://rrdp.example.test/notification.xml".to_string(),
            last_session_id: None,
            last_serial: None,
            first_seen_at: pack_time(0),
            last_seen_at: pack_time(1),
            last_sync_at: None,
            sync_state: RrdpSourceSyncState::Empty,
            last_snapshot_uri: None,
            last_snapshot_hash: None,
            last_error: Some("network timeout".to_string()),
        };
        source
            .validate_internal()
            .expect("source with optional fields validates");

        let owner = RrdpUriOwnerRecord {
            rsync_uri: "rsync://example.test/repo/object.cer".to_string(),
            notify_uri: "https://rrdp.example.test/notification.xml".to_string(),
            current_hash: None,
            last_confirmed_session_id: "session-1".to_string(),
            last_confirmed_serial: 5,
            last_changed_at: pack_time(5),
            owner_state: RrdpUriOwnerState::Withdrawn,
        };
        owner
            .validate_internal()
            .expect("owner without hash validates when withdrawn");
    }

    #[test]
    fn rrdp_source_binding_records_roundtrip_and_prefix_iteration() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let notify_uri = "https://rrdp.example.test/notification.xml";
        let source = sample_rrdp_source_record(notify_uri);
        store
            .put_rrdp_source_record(&source)
            .expect("put rrdp source record");
        let got_source = store
            .get_rrdp_source_record(notify_uri)
            .expect("get rrdp source record")
            .expect("rrdp source exists");
        assert_eq!(got_source, source);

        let member1 =
            sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/a.cer", 1);
        let member2 =
            sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/b.roa", 2);
        let other_member = sample_rrdp_source_member_record(
            "https://other.example.test/notification.xml",
            "rsync://other.example.test/repo/c.cer",
            3,
        );
        store
            .put_rrdp_source_member_record(&member1)
            .expect("put member1");
        store
            .put_rrdp_source_member_record(&member2)
            .expect("put member2");
        store
            .put_rrdp_source_member_record(&other_member)
            .expect("put other member");

        let mut members = store
            .list_rrdp_source_member_records(notify_uri)
            .expect("list rrdp source members");
        members.sort_by(|a, b| a.rsync_uri.cmp(&b.rsync_uri));
        assert_eq!(members, vec![member1.clone(), member2.clone()]);

        let got_member = store
            .get_rrdp_source_member_record(notify_uri, &member1.rsync_uri)
            .expect("get member1")
            .expect("member1 exists");
        assert_eq!(got_member, member1);

        let owner = sample_rrdp_uri_owner_record(notify_uri, &member1.rsync_uri);
        store
            .put_rrdp_uri_owner_record(&owner)
            .expect("put uri owner record");
        let got_owner = store
            .get_rrdp_uri_owner_record(&member1.rsync_uri)
            .expect("get uri owner record")
            .expect("uri owner exists");
        assert_eq!(got_owner, owner);
        store
            .delete_rrdp_uri_owner_record(&member1.rsync_uri)
            .expect("delete uri owner record");
        assert!(
            store
                .get_rrdp_uri_owner_record(&member1.rsync_uri)
                .expect("get deleted uri owner")
                .is_none()
        );

        let mut invalid_source =
            sample_rrdp_source_record("https://invalid.example/notification.xml");
        invalid_source.last_snapshot_hash = Some("bad".to_string());
        let err = store
            .put_rrdp_source_record(&invalid_source)
            .expect_err("invalid source hash must fail");
        assert!(err.to_string().contains("last_snapshot_hash"));

        let invalid_member = RrdpSourceMemberRecord {
            notify_uri: notify_uri.to_string(),
            rsync_uri: "rsync://example.test/repo/deleted.cer".to_string(),
            current_hash: None,
            object_type: None,
            present: true,
            last_confirmed_session_id: "session-1".to_string(),
            last_confirmed_serial: 10,
            last_changed_at: pack_time(10),
        };
        let err = store
            .put_rrdp_source_member_record(&invalid_member)
            .expect_err("present member without hash must fail");
        assert!(err.to_string().contains("current_hash is required"));
    }
    #[test]
    fn projection_batch_roundtrip_writes_repository_view_member_and_owner_records() {
        let dir = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(dir.path()).expect("open store");

        let view = RepositoryViewEntry {
            rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
            current_hash: Some(hex::encode([1u8; 32])),
            repository_source: Some("https://example.test/notify.xml".to_string()),
            object_type: Some("roa".to_string()),
            state: RepositoryViewState::Present,
        };
        let member = RrdpSourceMemberRecord {
            notify_uri: "https://example.test/notify.xml".to_string(),
            rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
            current_hash: Some(hex::encode([1u8; 32])),
            object_type: Some("roa".to_string()),
            present: true,
            last_confirmed_session_id: "session-1".to_string(),
            last_confirmed_serial: 7,
            last_changed_at: pack_time(1),
        };
        let owner = RrdpUriOwnerRecord {
            rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
            notify_uri: "https://example.test/notify.xml".to_string(),
            current_hash: Some(hex::encode([1u8; 32])),
            last_confirmed_session_id: "session-1".to_string(),
            last_confirmed_serial: 7,
            last_changed_at: pack_time(1),
            owner_state: RrdpUriOwnerState::Active,
        };

        store
            .put_projection_batch(&[view.clone()], &[member.clone()], &[owner.clone()])
            .expect("write projection batch");

        assert_eq!(
            store
                .get_repository_view_entry(&view.rsync_uri)
                .expect("get view")
                .expect("present view"),
            view
        );
        assert_eq!(
            store
                .get_rrdp_source_member_record(&member.notify_uri, &member.rsync_uri)
                .expect("get member")
                .expect("present member"),
            member
        );
        assert_eq!(
            store
                .get_rrdp_uri_owner_record(&owner.rsync_uri)
                .expect("get owner")
                .expect("present owner"),
            owner
        );
    }

    #[test]
    fn current_rrdp_source_member_helpers_filter_present_records() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let notify_uri = "https://rrdp.example.test/notification.xml";
        let mut present_a =
            sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/a.cer", 1);
        let mut withdrawn_b =
            sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/b.roa", 2);
        withdrawn_b.present = false;
        let present_c =
            sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/c.crl", 3);
        let other_source = sample_rrdp_source_member_record(
            "https://other.example.test/notification.xml",
            "rsync://other.example.test/repo/x.cer",
            4,
        );
        present_a.last_confirmed_serial = 10;

        store
            .put_rrdp_source_member_record(&present_a)
            .expect("put present a");
        store
            .put_rrdp_source_member_record(&withdrawn_b)
            .expect("put withdrawn b");
        store
            .put_rrdp_source_member_record(&present_c)
            .expect("put present c");
        store
            .put_rrdp_source_member_record(&other_source)
            .expect("put other source");

        let members = store
            .list_current_rrdp_source_members(notify_uri)
            .expect("list current members");
        assert_eq!(
            members
                .iter()
                .map(|record| record.rsync_uri.as_str())
                .collect::<Vec<_>>(),
            vec![
                "rsync://example.test/repo/a.cer",
                "rsync://example.test/repo/c.crl",
            ]
        );

        assert!(
            store
                .is_current_rrdp_source_member(notify_uri, &present_a.rsync_uri)
                .expect("current a")
        );
        assert!(
            !store
                .is_current_rrdp_source_member(notify_uri, &withdrawn_b.rsync_uri)
                .expect("withdrawn b")
        );
        assert!(
            !store
                .is_current_rrdp_source_member(notify_uri, &other_source.rsync_uri)
                .expect("other source")
        );
    }

    #[test]
    fn load_current_object_bytes_by_uri_uses_repository_view_and_raw_by_hash() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");

        let present_bytes = b"present-object".to_vec();
        let present_hash = sha256_hex(&present_bytes);
        let mut present_raw =
            RawByHashEntry::from_bytes(present_hash.clone(), present_bytes.clone());
        present_raw
            .origin_uris
            .push("rsync://example.test/repo/present.roa".to_string());
        present_raw.object_type = Some("roa".to_string());
        store
            .put_raw_by_hash_entry(&present_raw)
            .expect("put present raw");
        store
            .put_repository_view_entry(&RepositoryViewEntry {
                rsync_uri: "rsync://example.test/repo/present.roa".to_string(),
                current_hash: Some(present_hash),
                repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
                object_type: Some("roa".to_string()),
                state: RepositoryViewState::Present,
            })
            .expect("put present view");

        let replaced_bytes = b"replaced-object".to_vec();
        let replaced_hash = sha256_hex(&replaced_bytes);
        let mut replaced_raw =
            RawByHashEntry::from_bytes(replaced_hash.clone(), replaced_bytes.clone());
        replaced_raw
            .origin_uris
            .push("rsync://example.test/repo/replaced.cer".to_string());
        replaced_raw.object_type = Some("cer".to_string());
        store
            .put_raw_by_hash_entry(&replaced_raw)
            .expect("put replaced raw");
        store
            .put_repository_view_entry(&RepositoryViewEntry {
                rsync_uri: "rsync://example.test/repo/replaced.cer".to_string(),
                current_hash: Some(replaced_hash),
                repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
                object_type: Some("cer".to_string()),
                state: RepositoryViewState::Replaced,
            })
            .expect("put replaced view");

        store
            .put_repository_view_entry(&RepositoryViewEntry {
                rsync_uri: "rsync://example.test/repo/withdrawn.crl".to_string(),
                current_hash: Some(sha256_hex(b"withdrawn")),
                repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
                object_type: Some("crl".to_string()),
                state: RepositoryViewState::Withdrawn,
            })
            .expect("put withdrawn view");

        assert_eq!(
            store
                .load_current_object_bytes_by_uri("rsync://example.test/repo/present.roa")
                .expect("load present"),
            Some(present_bytes)
        );
        assert_eq!(
            store
                .load_current_object_bytes_by_uri("rsync://example.test/repo/replaced.cer")
                .expect("load replaced"),
            Some(replaced_bytes)
        );
        assert_eq!(
            store
                .load_current_object_bytes_by_uri("rsync://example.test/repo/withdrawn.crl")
                .expect("load withdrawn"),
            None
        );
        assert_eq!(
            store
                .load_current_object_bytes_by_uri("rsync://example.test/repo/missing.roa")
                .expect("load missing"),
            None
        );
    }

    #[test]
    fn load_current_object_bytes_by_uri_errors_when_raw_by_hash_is_missing() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");
        let rsync_uri = "rsync://example.test/repo/missing.cer";

        store
            .put_repository_view_entry(&RepositoryViewEntry {
                rsync_uri: rsync_uri.to_string(),
                current_hash: Some(hex::encode([0x11; 32])),
                repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
                object_type: Some("cer".to_string()),
                state: RepositoryViewState::Present,
            })
            .expect("put view");
        let err = store
            .load_current_object_bytes_by_uri(rsync_uri)
            .expect_err("missing raw_by_hash should error");
        assert!(matches!(err, StorageError::InvalidData { .. }));
    }

    #[test]
    fn load_current_object_with_hash_by_uri_returns_hash_and_bytes() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");
        let rsync_uri = "rsync://example.test/repo/present.roa";
        let bytes = b"present-object".to_vec();
        let hash = sha256_hex(&bytes);

        let mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone());
        raw.origin_uris.push(rsync_uri.to_string());
        raw.object_type = Some("roa".to_string());
        store.put_raw_by_hash_entry(&raw).expect("put raw");
        store
            .put_repository_view_entry(&RepositoryViewEntry {
                rsync_uri: rsync_uri.to_string(),
                current_hash: Some(hash.clone()),
                repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
                object_type: Some("roa".to_string()),
                state: RepositoryViewState::Present,
            })
            .expect("put view");

        let got = store
            .load_current_object_with_hash_by_uri(rsync_uri)
            .expect("load current object")
            .expect("current object exists");
        assert_eq!(got.current_hash_hex, hash);
        assert_eq!(got.current_hash, compute_sha256_32(&bytes));
        assert_eq!(got.bytes, bytes);
    }

    #[test]
    fn load_current_object_with_hash_by_uri_uses_internal_blob_cf_without_raw_entry() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");
        let rsync_uri = "rsync://example.test/repo/blob-only.roa";
        let bytes = b"blob-only-current-object".to_vec();
        let hash = sha256_hex(&bytes);

        store
            .put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
            .expect("put blob bytes");
        store
            .put_repository_view_entry(&RepositoryViewEntry {
                rsync_uri: rsync_uri.to_string(),
                current_hash: Some(hash.clone()),
                repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
                object_type: Some("roa".to_string()),
                state: RepositoryViewState::Present,
            })
            .expect("put view");

        let got = store
            .load_current_object_with_hash_by_uri(rsync_uri)
            .expect("load current object")
            .expect("current object exists");
        assert_eq!(got.current_hash_hex, hash);
        assert_eq!(got.current_hash, compute_sha256_32(&bytes));
        assert_eq!(got.bytes, bytes);
        assert!(
            store
                .get_raw_by_hash_entry(&got.current_hash_hex)
                .expect("get raw entry")
                .is_none()
        );
    }

    #[test]
    fn load_current_object_bytes_by_uri_uses_internal_blob_cf_without_raw_entry() {
        let td = tempfile::tempdir().expect("tempdir");
        let store = RocksStore::open(td.path()).expect("open rocksdb");
        let rsync_uri = "rsync://example.test/repo/blob-only-bytes.roa";
        let bytes = b"blob-only-current-object-bytes".to_vec();
        let hash = sha256_hex(&bytes);

        store
            .put_blob_bytes_batch(&[(hash, bytes.clone())])
            .expect("put blob bytes");
        store
            .put_repository_view_entry(&RepositoryViewEntry {
                rsync_uri: rsync_uri.to_string(),
                current_hash: Some(sha256_hex(&bytes)),
                repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
                object_type: Some("roa".to_string()),
                state: RepositoryViewState::Present,
            })
            .expect("put view");

        assert_eq!(
            store
                .load_current_object_bytes_by_uri(rsync_uri)
                .expect("load current object bytes"),
            Some(bytes)
        );
    }

    #[test]
    fn pack_file_can_lazy_load_bytes_from_external_raw_store() {
        let td = tempfile::tempdir().expect("tempdir");
        let raw_store = std::sync::Arc::new(
            ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store"),
        );
        let bytes = b"lazy-pack-file".to_vec();
        let sha256_hex = sha256_hex(&bytes);
        raw_store
            .put_raw_entry(&RawByHashEntry::from_bytes(
                sha256_hex.clone(),
                bytes.clone(),
            ))
            .expect("put raw entry");

        let file = PackFile::from_lazy_external_raw_store(
            "rsync://example.test/repo/a.roa",
            sha256_hex,
            compute_sha256_32(&bytes),
            raw_store,
        );

        assert_eq!(file.bytes().expect("lazy bytes"), bytes.as_slice());
        assert_eq!(file.bytes_cloned().expect("cloned bytes"), bytes);
    }
}
