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))
}

fn sha256_32(input: &[u8]) -> [u8; 32] {
    compute_sha256_32(input)
}

fn sample_child_certificate_cache_projection(
    cache_key_sha256_hex: String,
    child_cert_uri: &str,
    child_cert_sha256_hex: &str,
) -> ChildCertificateCacheProjection {
    ChildCertificateCacheProjection {
        schema_version: CHILD_CERTIFICATE_CACHE_SCHEMA_VERSION,
        algorithm_version: CHILD_CERTIFICATE_CACHE_ALGORITHM_VERSION,
        cache_key_sha256_hex,
        child_cert_uri: child_cert_uri.to_string(),
        child_cert_sha256_hex: child_cert_sha256_hex.to_string(),
        child_cert_serial: vec![1],
        issuer_ca_sha256_hex: sha256_hex(b"issuer-ca"),
        issuer_crl_uri: "rsync://example.test/repo/issuer.crl".to_string(),
        issuer_crl_sha256_hex: sha256_hex(b"issuer-crl"),
        ca_validation_context_digest: sha256_32(b"parent-context"),
        validation_policy_fingerprint: sha256_32(b"policy"),
        effective_not_before: pack_time(0),
        effective_until: pack_time(24),
        payload: ChildCertificateCachePayload::ChildCa {
            child_manifest_rsync_uri: format!("{child_cert_uri}.mft"),
            child_ski: "11".repeat(20),
            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/notify.xml".to_string()),
            child_effective_ip_resources: None,
            child_effective_as_resources: None,
        },
    }
}

#[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);
}

#[test]
fn parse_work_db_memory_profile_accepts_supported_values() {
    assert_eq!(
        parse_work_db_memory_profile("default"),
        Some(WorkDbMemoryProfile::Default)
    );
    assert_eq!(
        parse_work_db_memory_profile("none"),
        Some(WorkDbMemoryProfile::Default)
    );
    assert_eq!(
        parse_work_db_memory_profile("compact"),
        Some(WorkDbMemoryProfile::Compact)
    );
    assert_eq!(
        parse_work_db_memory_profile("low-memory"),
        Some(WorkDbMemoryProfile::Compact)
    );
    assert_eq!(parse_work_db_memory_profile("unexpected"), None);
}

#[test]
fn vcir_field_size_breakdown_counts_local_outputs_and_artifacts() {
    let vcir = sample_vcir("rsync://example.test/repo/current.mft");
    let breakdown = VcirFieldSizeBreakdown::from_vcir(&vcir);
    assert_eq!(breakdown.local_output_count, 2);
    assert_eq!(
        breakdown.local_output_payload_json_bytes,
        vcir.local_outputs
            .iter()
            .map(|output| output.payload_json().len() as u64)
            .sum::<u64>()
    );
    assert_eq!(
        breakdown.local_output_rule_hash_hex_bytes,
        vcir.local_outputs.len() as u64 * 64
    );
    assert_eq!(breakdown.related_artifact_count, 2);
    assert!(breakdown.related_artifact_uri_bytes > 0);
    assert_eq!(breakdown.child_entry_count, 1);
    assert!(breakdown.child_entry_uri_bytes > 0);
    assert_eq!(
        breakdown.local_output_old_projection_bytes(),
        breakdown.local_output_source_type_bytes
            + breakdown.local_output_source_hash_hex_bytes
            + breakdown.local_output_source_ee_hash_hex_bytes
            + breakdown.local_output_payload_json_bytes
            + breakdown.local_output_rule_hash_hex_bytes
    );
    assert!(
        breakdown.local_output_old_projection_bytes()
            > breakdown.local_output_typed_projection_bytes()
    );
}

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_type: VcirOutputType::Vrp,
                item_effective_until: pack_time(12),
                source_object_uri: "rsync://example.test/repo/object.roa".to_string(),
                source_object_type: VcirSourceObjectType::Roa,
                source_object_hash: sha256_32(&roa_bytes),
                source_ee_cert_hash: sha256_32(&ee_bytes),
                payload: VcirLocalOutputPayload::Vrp {
                    asn: 64496,
                    afi: crate::data_model::roa::RoaAfi::Ipv4,
                    prefix_len: 24,
                    addr: {
                        let mut addr = [0u8; 16];
                        addr[..4].copy_from_slice(&[203, 0, 113, 0]);
                        addr
                    },
                    max_length: 24,
                },
                rule_hash: sha256_32(b"vrp-rule-1"),
            },
            VcirLocalOutput {
                output_type: VcirOutputType::Aspa,
                item_effective_until: pack_time(10),
                source_object_uri: "rsync://example.test/repo/object.asa".to_string(),
                source_object_type: VcirSourceObjectType::Aspa,
                source_object_hash: sha256_32(b"aspa-object"),
                source_ee_cert_hash: sha256_32(b"aspa-ee-cert"),
                payload: VcirLocalOutputPayload::Aspa {
                    customer_as_id: 64496,
                    provider_as_ids: vec![64497],
                },
                rule_hash: sha256_32(b"aspa-rule-1"),
            },
        ],
        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,
                reject_reason: None,
            },
            VcirRelatedArtifact {
                artifact_role: VcirArtifactRole::CurrentCrl,
                artifact_kind: VcirArtifactKind::Crl,
                uri: Some("rsync://example.test/repo/current.crl".to_string()),
                sha256: sha256_hex(b"current-crl"),
                object_type: Some("crl".to_string()),
                validation_status: VcirArtifactValidationStatus::Accepted,
                reject_reason: None,
            },
        ],
        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()],
        },
    }
}

fn roa_cache_projection_context(vcir: &ValidatedCaInstanceResult) -> RoaCacheProjectionContext {
    let roa_output = vcir
        .local_outputs
        .iter()
        .find(|output| {
            output.output_type == VcirOutputType::Vrp
                && output.source_object_type == VcirSourceObjectType::Roa
        })
        .expect("sample VCIR has a ROA output");
    RoaCacheProjectionContext {
        ca_validation_context_digest: [0x31; 32],
        policy_fingerprint: [0x32; 32],
        object_meta: vec![RoaCacheObjectMeta {
            source_object_uri: roa_output.source_object_uri.clone(),
            source_object_hash: roa_output.source_object_hash,
            ee_serial: vec![0x01],
            crl_uri: vcir.current_crl_rsync_uri.clone(),
            earliest_safe_reuse_time: vcir.last_successful_validation_time.clone(),
        }],
    }
}

#[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 { .. })
    ));
}

#[test]
fn roa_cache_projection_from_vcir_keeps_only_roa_vrp_outputs() {
    let vcir = sample_vcir("rsync://example.test/repo/current.mft");
    let context = roa_cache_projection_context(&vcir);
    let projection = RoaCacheProjection::from_vcir_with_context(&vcir, Some(&context))
        .expect("projection build")
        .expect("projection exists");

    assert_eq!(projection.manifest_rsync_uri, vcir.manifest_rsync_uri);
    assert_eq!(projection.instance_effective_until, pack_time(12));
    assert_eq!(projection.crl_sha256_by_uri.len(), 1);
    assert_eq!(projection.entries.len(), 1);
    assert_eq!(
        projection.entries[0].source_object_uri,
        "rsync://example.test/repo/object.roa"
    );
    assert_eq!(
        projection.entries[0].outputs_effective_until_unix,
        12 * 3600
    );
    assert_eq!(projection.entries[0].outputs.len(), 1);
    assert!(matches!(
        projection.entries[0].outputs[0].payload,
        VcirLocalOutputPayload::Vrp { .. }
    ));
}

#[test]
fn roa_cache_projection_groups_multiple_outputs_by_roa_uri() {
    let mut vcir = sample_vcir("rsync://example.test/repo/current.mft");
    let mut second = vcir.local_outputs[0].clone();
    second.rule_hash = sha256_32(b"vrp-rule-2");
    if let VcirLocalOutputPayload::Vrp { max_length, .. } = &mut second.payload {
        *max_length = 25;
    }
    vcir.local_outputs.push(second);
    vcir.summary.local_vrp_count = 2;

    let context = roa_cache_projection_context(&vcir);
    let projection = RoaCacheProjection::from_vcir_with_context(&vcir, Some(&context))
        .expect("projection build")
        .expect("projection exists");

    assert_eq!(projection.entries.len(), 1);
    assert_eq!(projection.entries[0].outputs.len(), 2);
    assert_eq!(
        projection.entries[0].outputs_effective_until_unix,
        12 * 3600
    );
}

#[test]
fn publication_point_cache_projection_roundtrips_with_vcir() {
    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");
    let projection = PublicationPointCacheProjection::from_vcir_with_context(
        &vcir,
        "rsync://example.test/repo/".to_string(),
        Some("rsync://example.test/repo/ca.cer".to_string()),
        sha256_32(b"ca-cert"),
        sha256_32(b"manifest"),
        sha256_32(b"ta-context"),
        sha256_32(b"parent-context"),
        sha256_32(b"policy"),
    )
    .expect("build publication point projection");

    store
        .put_vcir_with_publication_point_cache_projection(&vcir, Some(&projection))
        .expect("put vcir with publication point projection");

    let got_vcir = store
        .get_vcir(&vcir.manifest_rsync_uri)
        .expect("get vcir")
        .expect("vcir exists");
    assert_eq!(got_vcir, vcir);
    let got_projection = store
        .get_publication_point_cache_projection(&vcir.manifest_rsync_uri)
        .expect("get publication point projection")
        .expect("projection exists");
    assert_eq!(got_projection, projection);
    assert_eq!(got_projection.outputs.len(), 2);
    assert_eq!(got_projection.children.len(), 1);
    assert_eq!(got_projection.related_objects.len(), 2);
}

#[test]
fn publication_point_cache_projection_index_updates_after_first_read() {
    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");
    let mut projection = PublicationPointCacheProjection::from_vcir_with_context(
        &vcir,
        "rsync://example.test/repo/".to_string(),
        Some("rsync://example.test/repo/ca.cer".to_string()),
        sha256_32(b"ca-cert"),
        sha256_32(b"manifest-old"),
        sha256_32(b"ta-context"),
        sha256_32(b"parent-context"),
        sha256_32(b"policy"),
    )
    .expect("build old publication point projection");

    store
        .put_vcir_with_publication_point_cache_projection(&vcir, Some(&projection))
        .expect("put old projection");
    let got_old = store
        .get_publication_point_cache_projection_cached(&vcir.manifest_rsync_uri)
        .expect("get old projection")
        .expect("old projection exists");
    assert_eq!(got_old.manifest_sha256, sha256_32(b"manifest-old"));

    projection.manifest_sha256 = sha256_32(b"manifest-new");
    store
        .put_vcir_with_publication_point_cache_projection(&vcir, Some(&projection))
        .expect("put new projection");
    let got_new = store
        .get_publication_point_cache_projection_cached(&vcir.manifest_rsync_uri)
        .expect("get new projection")
        .expect("new projection exists");
    assert_eq!(got_new.manifest_sha256, sha256_32(b"manifest-new"));
}

#[test]
fn publication_point_cache_projection_cached_empty_db_accepts_bounded_new_entries() {
    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");
    let projection = PublicationPointCacheProjection::from_vcir_with_context(
        &vcir,
        "rsync://example.test/repo/".to_string(),
        Some("rsync://example.test/repo/ca.cer".to_string()),
        sha256_32(b"ca-cert"),
        sha256_32(b"manifest"),
        sha256_32(b"ta-context"),
        sha256_32(b"parent-context"),
        sha256_32(b"policy"),
    )
    .expect("build publication point projection");

    assert!(
        store
            .get_publication_point_cache_projection_cached(&vcir.manifest_rsync_uri)
            .expect("cached empty lookup")
            .is_none()
    );
    store
        .put_vcir_with_publication_point_cache_projection(&vcir, Some(&projection))
        .expect("put projection after cached empty lookup");
    assert_eq!(
        store
            .get_publication_point_cache_projection_cached(&vcir.manifest_rsync_uri)
            .expect("cached lookup sees bounded new entry")
            .expect("cached projection exists"),
        projection
    );
    assert_eq!(
        store
            .get_publication_point_cache_projection(&vcir.manifest_rsync_uri)
            .expect("direct db lookup")
            .expect("projection exists"),
        projection
    );
}

#[test]
fn publication_point_cache_mmap_index_refresh_roundtrips_after_reopen() {
    let td = tempfile::tempdir().expect("tempdir");
    let db_path = td.path().join("work-db");
    let vcir = sample_vcir("rsync://example.test/repo/current.mft");
    let projection = PublicationPointCacheProjection::from_vcir_with_context(
        &vcir,
        "rsync://example.test/repo/".to_string(),
        Some("rsync://example.test/repo/ca.cer".to_string()),
        sha256_32(b"ca-cert"),
        sha256_32(b"manifest"),
        sha256_32(b"ta-context"),
        sha256_32(b"parent-context"),
        sha256_32(b"policy"),
    )
    .expect("build publication point projection");

    {
        let store = RocksStore::open(&db_path).expect("open rocksdb");
        store
            .put_vcir_with_publication_point_cache_projection(&vcir, Some(&projection))
            .expect("put projection");
        let stats = store
            .refresh_publication_point_cache_mmap_index()
            .expect("refresh mmap index")
            .expect("refresh stats");
        assert_eq!(stats.new_entries, 1);
        assert_eq!(stats.state, "written");
    }

    let store = RocksStore::open(&db_path).expect("reopen rocksdb");
    let got = store
        .get_publication_point_cache_projection_cached(&vcir.manifest_rsync_uri)
        .expect("get cached projection from mmap")
        .expect("projection exists");
    assert_eq!(got, projection);
}

#[test]
fn publication_point_cache_mmap_index_dirty_overlay_wins_and_refreshes_segment() {
    let td = tempfile::tempdir().expect("tempdir");
    let db_path = td.path().join("work-db");
    let vcir = sample_vcir("rsync://example.test/repo/current.mft");
    let mut projection = PublicationPointCacheProjection::from_vcir_with_context(
        &vcir,
        "rsync://example.test/repo/".to_string(),
        Some("rsync://example.test/repo/ca.cer".to_string()),
        sha256_32(b"ca-cert"),
        sha256_32(b"manifest-old"),
        sha256_32(b"ta-context"),
        sha256_32(b"parent-context"),
        sha256_32(b"policy"),
    )
    .expect("build publication point projection");

    {
        let store = RocksStore::open(&db_path).expect("open rocksdb");
        store
            .put_vcir_with_publication_point_cache_projection(&vcir, Some(&projection))
            .expect("put old projection");
        store
            .refresh_publication_point_cache_mmap_index()
            .expect("refresh base mmap index");
    }

    {
        let store = RocksStore::open(&db_path).expect("reopen rocksdb");
        assert_eq!(
            store
                .get_publication_point_cache_projection_cached(&vcir.manifest_rsync_uri)
                .expect("get old projection")
                .expect("old projection exists")
                .manifest_sha256,
            sha256_32(b"manifest-old")
        );
        projection.manifest_sha256 = sha256_32(b"manifest-new");
        store
            .put_vcir_with_publication_point_cache_projection(&vcir, Some(&projection))
            .expect("put new projection");
        assert_eq!(
            store
                .get_publication_point_cache_projection_cached(&vcir.manifest_rsync_uri)
                .expect("get dirty projection")
                .expect("dirty projection exists")
                .manifest_sha256,
            sha256_32(b"manifest-new")
        );
        let stats = store
            .refresh_publication_point_cache_mmap_index()
            .expect("refresh dirty mmap segment")
            .expect("refresh stats");
        assert_eq!(stats.state, "segment_written");
        assert_eq!(stats.dirty_entries, 1);
    }

    let store = RocksStore::open(&db_path).expect("reopen rocksdb after segment");
    let got = store
        .get_publication_point_cache_projection_cached(&vcir.manifest_rsync_uri)
        .expect("get refreshed projection")
        .expect("projection exists");
    assert_eq!(got.manifest_sha256, sha256_32(b"manifest-new"));
}

#[test]
fn publication_point_cache_mmap_index_write_before_read_refreshes_segment() {
    let td = tempfile::tempdir().expect("tempdir");
    let db_path = td.path().join("work-db");
    let vcir = sample_vcir("rsync://example.test/repo/current.mft");
    let mut projection = PublicationPointCacheProjection::from_vcir_with_context(
        &vcir,
        "rsync://example.test/repo/".to_string(),
        Some("rsync://example.test/repo/ca.cer".to_string()),
        sha256_32(b"ca-cert"),
        sha256_32(b"manifest-old"),
        sha256_32(b"ta-context"),
        sha256_32(b"parent-context"),
        sha256_32(b"policy"),
    )
    .expect("build publication point projection");

    {
        let store = RocksStore::open(&db_path).expect("open rocksdb");
        store
            .put_vcir_with_publication_point_cache_projection(&vcir, Some(&projection))
            .expect("put old projection");
        let stats = store
            .refresh_publication_point_cache_mmap_index()
            .expect("refresh base mmap index")
            .expect("refresh stats");
        assert_eq!(stats.state, "written");
    }

    {
        let store = RocksStore::open(&db_path).expect("reopen rocksdb");
        projection.manifest_sha256 = sha256_32(b"manifest-new");
        store
            .put_vcir_with_publication_point_cache_projection(&vcir, Some(&projection))
            .expect("put new projection before any cached read");
        let stats = store
            .refresh_publication_point_cache_mmap_index()
            .expect("refresh dirty mmap segment")
            .expect("refresh stats");
        assert_eq!(stats.state, "segment_written");
        assert_eq!(stats.dirty_entries, 1);
        assert_eq!(stats.new_entries, 1);
    }

    let store = RocksStore::open(&db_path).expect("reopen rocksdb after segment");
    let got = store
        .get_publication_point_cache_projection_cached(&vcir.manifest_rsync_uri)
        .expect("get refreshed projection")
        .expect("projection exists");
    assert_eq!(got.manifest_sha256, sha256_32(b"manifest-new"));
}

#[test]
fn publication_point_cache_mmap_index_delete_before_read_refreshes_tombstone_segment() {
    let td = tempfile::tempdir().expect("tempdir");
    let db_path = td.path().join("work-db");
    let vcir = sample_vcir("rsync://example.test/repo/current.mft");
    let projection = PublicationPointCacheProjection::from_vcir_with_context(
        &vcir,
        "rsync://example.test/repo/".to_string(),
        Some("rsync://example.test/repo/ca.cer".to_string()),
        sha256_32(b"ca-cert"),
        sha256_32(b"manifest"),
        sha256_32(b"ta-context"),
        sha256_32(b"parent-context"),
        sha256_32(b"policy"),
    )
    .expect("build publication point projection");

    {
        let store = RocksStore::open(&db_path).expect("open rocksdb");
        store
            .put_vcir_with_publication_point_cache_projection(&vcir, Some(&projection))
            .expect("put projection");
        let stats = store
            .refresh_publication_point_cache_mmap_index()
            .expect("refresh base mmap index")
            .expect("refresh stats");
        assert_eq!(stats.state, "written");
    }

    {
        let store = RocksStore::open(&db_path).expect("reopen rocksdb");
        store
            .replace_vcir_manifest_replay_meta_and_projection_action(
                &vcir,
                None,
                PublicationPointCacheProjectionWriteAction::Delete {
                    manifest_rsync_uri: &vcir.manifest_rsync_uri,
                },
            )
            .expect("delete projection before any cached read");
        assert!(
            store
                .get_publication_point_cache_projection_cached(&vcir.manifest_rsync_uri)
                .expect("dirty tombstone lookup")
                .is_none()
        );
        let stats = store
            .refresh_publication_point_cache_mmap_index()
            .expect("refresh tombstone mmap segment")
            .expect("refresh stats");
        assert_eq!(stats.state, "segment_written");
        assert_eq!(stats.dirty_entries, 1);
        assert_eq!(stats.new_entries, 1);
    }

    let store = RocksStore::open(&db_path).expect("reopen rocksdb after tombstone segment");
    assert!(
        store
            .get_publication_point_cache_projection_cached(&vcir.manifest_rsync_uri)
            .expect("tombstone shadows old current index")
            .is_none()
    );
    assert!(
        store
            .get_publication_point_cache_projection(&vcir.manifest_rsync_uri)
            .expect("direct db lookup")
            .is_none()
    );
}

#[test]
fn publication_point_cache_projection_rejects_version_mismatch() {
    let vcir = sample_vcir("rsync://example.test/repo/current.mft");
    let mut projection = PublicationPointCacheProjection::from_vcir_with_context(
        &vcir,
        "rsync://example.test/repo/".to_string(),
        Some("rsync://example.test/repo/ca.cer".to_string()),
        sha256_32(b"ca-cert"),
        sha256_32(b"manifest"),
        sha256_32(b"ta-context"),
        sha256_32(b"parent-context"),
        sha256_32(b"policy"),
    )
    .expect("build publication point projection");
    projection.schema_version = PUBLICATION_POINT_CACHE_SCHEMA_VERSION + 1;
    assert!(matches!(
        projection.validate_internal(),
        Err(StorageError::InvalidData { .. })
    ));
}

#[test]
fn roa_cache_projection_rejects_duplicate_uri_with_different_hash() {
    let mut vcir = sample_vcir("rsync://example.test/repo/current.mft");
    let mut duplicate = vcir.local_outputs[0].clone();
    duplicate.source_object_hash = sha256_32(b"different-roa");
    duplicate.rule_hash = sha256_32(b"vrp-rule-2");
    vcir.local_outputs.push(duplicate);
    vcir.summary.local_vrp_count = 2;

    let context = roa_cache_projection_context(&vcir);
    let err = RoaCacheProjection::from_vcir_with_context(&vcir, Some(&context))
        .expect_err("same ROA URI with different hash must fail");
    assert!(err.to_string().contains("source object hash mismatch"));
}

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 memory_snapshot_includes_work_db_and_external_stores() {
    let td = tempfile::tempdir().expect("tempdir");
    let store = RocksStore::open_with_external_stores(
        &td.path().join("main-db"),
        Some(&td.path().join("raw-store.db")),
        Some(&td.path().join("repo-bytes.db")),
    )
    .expect("open store");

    let snapshot = store.memory_snapshot();
    let labels: Vec<&str> = snapshot
        .databases
        .iter()
        .map(|db| db.label.as_str())
        .collect();
    assert_eq!(labels, vec!["work-db", "raw-store.db", "repo-bytes.db"]);
    assert!(
        snapshot.databases[0]
            .column_families
            .iter()
            .any(|cf| cf.name == CF_REPOSITORY_VIEW)
    );
    serde_json::to_value(&snapshot).expect("serialize memory snapshot");
}

#[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");
    let roa_context = roa_cache_projection_context(&vcir);
    store
        .put_vcir_with_projections(&vcir, Some(&roa_context), None)
        .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 projection = store
        .get_roa_cache_projection(&vcir.manifest_rsync_uri)
        .expect("get roa cache projection")
        .expect("roa cache projection exists");
    assert_eq!(projection.manifest_rsync_uri, vcir.manifest_rsync_uri);
    assert_eq!(projection.entries.len(), 1);
    assert_eq!(
        projection.entries[0].source_object_uri,
        "rsync://example.test/repo/object.roa"
    );

    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()
    );
    assert!(
        store
            .get_roa_cache_projection(&vcir.manifest_rsync_uri)
            .expect("get deleted roa cache projection")
            .is_none()
    );
}

#[test]
fn clear_vcir_reuse_records_preserves_manifest_replay_meta() {
    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 replay_meta = store
        .get_manifest_replay_meta(&vcir.manifest_rsync_uri)
        .expect("get replay meta")
        .expect("replay meta exists");

    let cleared = store
        .clear_vcir_reuse_records()
        .expect("clear reusable VCIR records");
    assert_eq!(cleared.vcir_records, 1);
    assert_eq!(cleared.failed_fetch_identity_records, 0);
    assert!(store
        .get_vcir(&vcir.manifest_rsync_uri)
        .expect("get cleared vcir")
        .is_none());
    assert_eq!(
        store
            .get_manifest_replay_meta(&vcir.manifest_rsync_uri)
            .expect("get preserved replay meta"),
        Some(replay_meta)
    );
}

#[test]
fn verification_only_materializes_only_contract_selected_fallback_vcirs() {
    fn reuse_identity() -> VcirFailedFetchReuseIdentity {
        VcirFailedFetchReuseIdentity {
            current_ca_sha256: [0x11; 32],
            ta_context_digest: [0x22; 32],
            ca_validation_context_digest: [0x33; 32],
            policy_fingerprint: [0x44; 32],
            effective_not_before: pack_time(0),
            effective_until: pack_time(12),
        }
    }

    let td = tempfile::tempdir().expect("tempdir");
    let store = RocksStore::open(td.path()).expect("open rocksdb");
    let selected = sample_vcir("rsync://example.test/repo/selected.mft");
    let unselected = sample_vcir("rsync://example.test/repo/unselected.mft");
    store
        .put_vcir_with_failed_fetch_reuse_identity(&selected, &reuse_identity())
        .expect("put selected fallback vcir");
    store
        .put_vcir_with_failed_fetch_reuse_identity(&unselected, &reuse_identity())
        .expect("put unselected fallback vcir");
    let selection = crate::verification_only::fallback_replay_selection_for_manifest(
        &store,
        &selected.manifest_rsync_uri,
    )
    .expect("select fallback vcir");
    let normal_audits = vec![crate::audit::PublicationPointAudit {
        manifest_rsync_uri: selected.manifest_rsync_uri.clone(),
        source: "vcir_current_instance".to_string(),
        ..crate::audit::PublicationPointAudit::default()
    }];
    assert_eq!(
        crate::verification_only::collect_fallback_replay_selections(&store, &normal_audits)
            .expect("collect normal fallback selection"),
        vec![selection.clone()]
    );

    let mut tampered = selection.clone();
    tampered.vcir_sha256 = "00".repeat(32);
    assert!(crate::verification_only::materialize_fallback_replay_selections(
        &store,
        &[tampered]
    )
    .expect_err("tampered contract hash must fail before cleanup")
    .contains("hash mismatch"));
    assert_eq!(store.list_vcirs().expect("VCIRs remain after hash rejection").len(), 2);

    let summary = crate::verification_only::materialize_fallback_replay_selections(
        &store,
        &[selection.clone()],
    )
    .expect("materialize contract fallback selection");
    assert_eq!(summary.contract_selection_count, 1);
    assert_eq!(summary.source_vcir_records_cleared, 2);
    assert_eq!(summary.source_reuse_identity_records_cleared, 2);
    assert_eq!(summary.imported_selection_count, 1);
    assert_eq!(summary.scratch_vcir_count, 1);
    assert_eq!(
        crate::verification_only::fallback_replay_selection_for_manifest(
            &store,
            &selected.manifest_rsync_uri,
        )
        .expect("selected fallback remains"),
        selection
    );
    assert!(store
        .get_vcir(&unselected.manifest_rsync_uri)
        .expect("get unselected fallback")
        .is_none());
    assert!(store
        .get_manifest_replay_meta(&unselected.manifest_rsync_uri)
        .expect("get unselected replay meta")
        .is_some());
}

#[test]
fn transport_prefetch_snapshot_roundtrips() {
    use crate::parallel::transport_prefetch::{
        TransportPrefetchDedupKey, TransportPrefetchMode, TransportPrefetchRepoIdentity,
        TransportPrefetchRequest, TransportPrefetchRequester, TransportPrefetchSnapshot,
    };
    use crate::policy::SyncPreference;

    let td = tempfile::tempdir().expect("tempdir");
    let store = RocksStore::open(td.path()).expect("open rocksdb");
    assert!(
        store
            .get_transport_prefetch_snapshot()
            .expect("get empty prefetch snapshot")
            .is_none()
    );

    let snapshot = TransportPrefetchSnapshot::new(
        SyncPreference::RrdpThenRsync,
        vec![TransportPrefetchRequest {
            dedup_key: TransportPrefetchDedupKey::RrdpNotify {
                notification_uri: "https://example.test/notification.xml".to_string(),
            },
            rsync_scope_uri: "rsync://example.test/repo/".to_string(),
            rsync_failure_scope_uri: Some("rsync://example.test/".to_string()),
            repo_identity: TransportPrefetchRepoIdentity {
                notification_uri: Some("https://example.test/notification.xml".to_string()),
                rsync_base_uri: "rsync://example.test/repo/".to_string(),
            },
            mode: TransportPrefetchMode::Rrdp,
            last_result: None,
            last_rsync_result: None,
            tal_id: "apnic".to_string(),
            rir_id: "apnic".to_string(),
            priority: 0,
            requesters: vec![TransportPrefetchRequester {
                tal_id: "apnic".to_string(),
                rir_id: "apnic".to_string(),
                parent_node_id: None,
                ca_instance_handle_id: "apnic:rsync://example.test/repo/root.mft".to_string(),
                publication_point_rsync_uri: "rsync://example.test/repo/".to_string(),
                manifest_rsync_uri: "rsync://example.test/repo/root.mft".to_string(),
            }],
        }],
    );
    store
        .put_transport_prefetch_snapshot(&snapshot)
        .expect("put prefetch snapshot");
    let got = store
        .get_transport_prefetch_snapshot()
        .expect("get prefetch snapshot")
        .expect("snapshot exists");
    assert_eq!(got, snapshot);
}

#[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 summarize_vcir_storage_aggregates_values_and_field_sizes() {
    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 summary = store.summarize_vcir_storage().expect("summarize vcirs");
    let mut expected_fields = VcirFieldSizeBreakdown::default();
    expected_fields.add_assign(&VcirFieldSizeBreakdown::from_vcir(&vcir1));
    expected_fields.add_assign(&VcirFieldSizeBreakdown::from_vcir(&vcir2));

    assert_eq!(summary.entry_count, 2);
    assert!(summary.vcir_value_bytes > 0);
    assert!(summary.vcir_value_bytes_max > 0);
    assert!(summary.vcir_value_bytes_max_manifest_rsync_uri.is_some());
    assert_eq!(summary.top_entries_by_vcir_value_bytes.len(), 2);
    assert!(
        summary.top_entries_by_vcir_value_bytes[0].vcir_value_bytes
            >= summary.top_entries_by_vcir_value_bytes[1].vcir_value_bytes
    );
    assert_eq!(summary.field_sizes, expected_fields);
    assert!(summary.core_fields.manifest_rsync_uri_bytes > 0);
    assert!(summary.ccr_projection.manifest_sha256_bytes > 0);
    assert!(summary.child_resources.effective_ip_resource_cbor_bytes > 0);
    assert_eq!(
        summary.local_output_old_projection_bytes,
        expected_fields.local_output_old_projection_bytes()
    );
    assert_eq!(
        summary.local_output_typed_projection_bytes,
        expected_fields.local_output_typed_projection_bytes()
    );
    assert_eq!(
        summary.local_output_projection_saved_bytes,
        expected_fields.local_output_projection_saved_bytes()
    );
}

#[test]
fn replace_vcir_and_manifest_replay_meta_replaces_current_entry() {
    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_type: VcirOutputType::Vrp,
        item_effective_until: pack_time(10),
        source_object_uri: "rsync://example.test/repo/old.roa".to_string(),
        source_object_type: VcirSourceObjectType::Roa,
        source_object_hash: sha256_32(b"old-roa"),
        source_ee_cert_hash: sha256_32(b"old-ee"),
        payload: VcirLocalOutputPayload::Vrp {
            asn: 64496,
            afi: crate::data_model::roa::RoaAfi::Ipv4,
            prefix_len: 24,
            addr: {
                let mut addr = [0u8; 16];
                addr[..4].copy_from_slice(&[203, 0, 113, 0]);
                addr
            },
            max_length: 24,
        },
        rule_hash: sha256_32(b"old-rule"),
    }];
    previous.summary.local_vrp_count = 1;
    previous.summary.local_aspa_count = 0;
    previous.summary.local_router_key_count = 0;
    let previous_roa_context = roa_cache_projection_context(&previous);
    let previous_timing = store
        .replace_vcir_manifest_replay_meta_and_projections(
            &previous,
            Some(&previous_roa_context),
            None,
        )
        .expect("store previous vcir");
    assert!(previous_timing.vcir_value_bytes > 0);
    assert!(previous_timing.replay_meta_value_bytes > 0);
    assert!(previous_timing.roa_cache_projection_value_bytes > 0);
    assert_eq!(
        previous_timing.total_encoded_bytes,
        previous_timing.vcir_value_bytes
            + previous_timing.replay_meta_value_bytes
            + previous_timing.roa_cache_projection_value_bytes
    );
    assert!(
        store
            .get_roa_cache_projection(&previous.manifest_rsync_uri)
            .expect("get previous projection")
            .is_some()
    );

    let mut current = sample_vcir("rsync://example.test/repo/current.mft");
    current.local_outputs = vec![VcirLocalOutput {
        output_type: VcirOutputType::Aspa,
        item_effective_until: pack_time(11),
        source_object_uri: "rsync://example.test/repo/new.asa".to_string(),
        source_object_type: VcirSourceObjectType::Aspa,
        source_object_hash: sha256_32(b"new-aspa"),
        source_ee_cert_hash: sha256_32(b"new-ee"),
        payload: VcirLocalOutputPayload::Aspa {
            customer_as_id: 64496,
            provider_as_ids: vec![64497],
        },
        rule_hash: sha256_32(b"new-rule"),
    }];
    current.summary.local_vrp_count = 0;
    current.summary.local_aspa_count = 1;
    let current_timing = store
        .replace_vcir_and_manifest_replay_meta(&current)
        .expect("replace vcir and replay meta");
    assert!(current_timing.vcir_value_bytes > 0);
    assert!(current_timing.replay_meta_value_bytes > 0);
    assert_eq!(current_timing.roa_cache_projection_value_bytes, 0);
    assert_eq!(
        current_timing.total_encoded_bytes,
        current_timing.vcir_value_bytes + current_timing.replay_meta_value_bytes
    );

    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_roa_cache_projection(&current.manifest_rsync_uri)
            .expect("get current projection")
            .is_none()
    );
}

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

    let first_key = sha256_hex(b"child-cache-key-first");
    let missing_key = sha256_hex(b"child-cache-key-missing");
    let second_key = sha256_hex(b"child-cache-key-second");
    let first_hash = sha256_hex(b"first-child-cert");
    let second_hash = sha256_hex(b"second-child-cert");
    let first = sample_child_certificate_cache_projection(
        first_key.clone(),
        "rsync://example.test/repo/first.cer",
        &first_hash,
    );
    let second = sample_child_certificate_cache_projection(
        second_key.clone(),
        "rsync://example.test/repo/second.cer",
        &second_hash,
    );

    store
        .put_child_certificate_cache_projection(&first)
        .expect("put first projection");
    store
        .put_child_certificate_cache_projection(&second)
        .expect("put second projection");

    let got = store
        .get_child_certificate_cache_projections_batch(&[
            second_key.clone(),
            missing_key,
            first_key.clone(),
        ])
        .expect("batch get child projections");

    assert_eq!(got.len(), 3);
    assert_eq!(
        got[0]
            .as_ref()
            .expect("second projection")
            .child_cert_sha256_hex,
        second_hash
    );
    assert!(got[1].is_none());
    assert_eq!(
        got[2]
            .as_ref()
            .expect("first projection")
            .child_cert_sha256_hex,
        first_hash
    );
}

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

    let manifest_uri = "rsync://example.test/repo/parent.mft";
    let first_key = sha256_hex(b"child-cache-mmap-key-first");
    let missing_key = sha256_hex(b"child-cache-mmap-key-missing");
    let second_key = sha256_hex(b"child-cache-mmap-key-second");
    let first_hash = sha256_hex(b"first-child-cert-mmap");
    let second_hash = sha256_hex(b"second-child-cert-mmap");
    let first = sample_child_certificate_cache_projection(
        first_key.clone(),
        "rsync://example.test/repo/first.cer",
        &first_hash,
    );
    let second = sample_child_certificate_cache_projection(
        second_key.clone(),
        "rsync://example.test/repo/second.cer",
        &second_hash,
    );

    assert!(
        store
            .get_child_certificate_cache_projections_mmap_segment(
                manifest_uri,
                &[first_key.clone()]
            )
            .expect("missing segment lookup")
            .is_none()
    );

    let write_stats = store
        .write_child_certificate_cache_mmap_segment(manifest_uri, &[first.clone(), second.clone()])
        .expect("write child projection segment");
    assert_eq!(write_stats.new_entries, 2);
    assert!(write_stats.file_bytes > 0);

    let got = store
        .get_child_certificate_cache_projections_mmap_segment(
            manifest_uri,
            &[second_key.clone(), missing_key, first_key.clone()],
        )
        .expect("lookup child projection segment")
        .expect("segment exists");

    assert_eq!(got.hits, 2);
    assert_eq!(got.misses, 1);
    assert!(got.file_bytes > 0);
    assert_eq!(got.projections.len(), 3);
    assert_eq!(
        got.projections[0]
            .as_ref()
            .expect("second projection")
            .child_cert_sha256_hex,
        second_hash
    );
    assert!(got.projections[1].is_none());
    assert_eq!(
        got.projections[2]
            .as_ref()
            .expect("first projection")
            .child_cert_sha256_hex,
        first_hash
    );
}

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

    let manifest_uri = "rsync://example.test/repo/parent-overlay.mft";
    let first_key = sha256_hex(b"child-cache-overlay-key-first");
    let second_key = sha256_hex(b"child-cache-overlay-key-second");
    let missing_key = sha256_hex(b"child-cache-overlay-key-missing");
    let first_hash = sha256_hex(b"first-child-cert-overlay");
    let old_second_hash = sha256_hex(b"old-second-child-cert-overlay");
    let new_second_hash = sha256_hex(b"new-second-child-cert-overlay");
    let first = sample_child_certificate_cache_projection(
        first_key.clone(),
        "rsync://example.test/repo/first-overlay.cer",
        &first_hash,
    );
    let old_second = sample_child_certificate_cache_projection(
        second_key.clone(),
        "rsync://example.test/repo/second-overlay.cer",
        &old_second_hash,
    );
    let new_second = sample_child_certificate_cache_projection(
        second_key.clone(),
        "rsync://example.test/repo/second-overlay.cer",
        &new_second_hash,
    );

    store
        .write_child_certificate_cache_mmap_segment(
            manifest_uri,
            &[first.clone(), old_second.clone()],
        )
        .expect("write initial segment");
    let stats = store
        .write_child_certificate_cache_mmap_segment_overlay(
            manifest_uri,
            &[first_key.clone(), second_key.clone(), missing_key.clone()],
            std::slice::from_ref(&new_second),
        )
        .expect("write segment overlay");
    assert_eq!(stats.new_entries, 2);

    let got = store
        .get_child_certificate_cache_projections_mmap_segment(
            manifest_uri,
            &[first_key.clone(), second_key.clone(), missing_key],
        )
        .expect("lookup segment")
        .expect("segment exists");
    assert_eq!(got.hits, 2);
    assert_eq!(got.misses, 1);
    assert_eq!(
        got.projections[0]
            .as_ref()
            .expect("first projection")
            .child_cert_sha256_hex,
        first_hash
    );
    assert_eq!(
        got.projections[1]
            .as_ref()
            .expect("updated second projection")
            .child_cert_sha256_hex,
        new_second_hash
    );
    assert!(got.projections[2].is_none());
}

#[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);
}

#[test]
fn pack_file_can_lazy_load_bytes_from_external_repo_bytes_store() {
    let td = tempfile::tempdir().expect("tempdir");
    let repo_bytes_store = std::sync::Arc::new(
        ExternalRepoBytesDb::open(td.path().join("repo-bytes.db")).expect("open repo bytes"),
    );
    let bytes = b"repo-object-pack-file".to_vec();
    let sha256_hex = sha256_hex(&bytes);
    repo_bytes_store
        .put_blob_bytes_batch(&[(sha256_hex.clone(), bytes.clone())])
        .expect("put repo bytes");

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

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

#[test]
fn read_only_checkpoint_isolated_from_source_work_db() {
    let td = tempfile::tempdir().expect("tempdir");
    let source_path = td.path().join("source-work-db");
    let checkpoint_path = td.path().join("checkpoint-work-db");
    let uri = "rsync://example.test/repo/a.roa";
    let bytes = b"checkpoint-object";
    let hash = sha256_hex(bytes);
    {
        let source = RocksStore::open(&source_path).expect("open source");
        source
            .put_blob_bytes_batch(&[(hash.clone(), bytes.to_vec())])
            .expect("put blob");
        source
            .put_repository_view_entry(&RepositoryViewEntry {
                rsync_uri: uri.to_string(),
                current_hash: Some(hash),
                repository_source: Some("fixture".to_string()),
                object_type: Some("roa".to_string()),
                state: RepositoryViewState::Present,
            })
            .expect("put view");
    }

    RocksStore::create_read_only_checkpoint(&source_path, &checkpoint_path)
        .expect("create checkpoint");
    let checkpoint = RocksStore::open(&checkpoint_path).expect("open checkpoint");
    checkpoint
        .delete_repository_view_entry(uri)
        .expect("delete checkpoint entry");
    drop(checkpoint);

    let source = RocksStore::open(&source_path).expect("reopen source");
    assert!(
        source
            .get_repository_view_entry(uri)
            .expect("get source entry")
            .is_some()
    );
}

#[test]
fn read_only_external_repo_bytes_rejects_writes() {
    let td = tempfile::tempdir().expect("tempdir");
    let path = td.path().join("repo-bytes.db");
    let hash = sha256_hex(b"repo-object");
    {
        let writable = ExternalRepoBytesDb::open(&path).expect("open writable repo bytes");
        writable
            .put_blob_bytes_batch(&[(hash.clone(), b"repo-object".to_vec())])
            .expect("seed repo bytes");
    }
    let read_only = ExternalRepoBytesDb::open_read_only(&path).expect("open read-only repo bytes");
    assert_eq!(
        read_only
            .get_blob_bytes(&hash)
            .expect("read repo bytes")
            .expect("blob"),
        b"repo-object"
    );
    assert!(
        read_only
            .put_blob_bytes_batch(&[(hash, b"repo-object".to_vec())])
            .is_err()
    );
}

#[test]
fn read_only_store_accepts_only_idempotent_existing_blob_apply() {
    let td = tempfile::tempdir().expect("tempdir");
    let work_db_path = td.path().join("work-db");
    let repo_bytes_path = td.path().join("repo-bytes.db");
    let bytes = b"repo-object".to_vec();
    let hash = sha256_hex(&bytes);
    {
        let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("open repo bytes");
        repo_bytes
            .put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
            .expect("seed repo bytes");
    }
    let store =
        RocksStore::open_with_external_repo_bytes_read_only(&work_db_path, &repo_bytes_path)
            .expect("open read-only store");
    store
        .put_blob_bytes_batch(&[(hash.clone(), bytes)])
        .expect("idempotent apply");
    assert!(
        store
            .put_blob_bytes_batch(&[(hash, b"different".to_vec())])
            .is_err()
    );
}

#[test]
fn repository_blob_verification_detects_missing_external_blob() {
    let td = tempfile::tempdir().expect("tempdir");
    let repo_bytes_path = td.path().join("repo-bytes.db");
    ExternalRepoBytesDb::open(&repo_bytes_path).expect("create repo bytes");
    let store = RocksStore::open_with_external_repo_bytes_read_only(
        &td.path().join("work-db"),
        &repo_bytes_path,
    )
    .expect("open work db");
    store
        .put_repository_view_entry(&RepositoryViewEntry {
            rsync_uri: "rsync://example.test/repo/missing.roa".to_string(),
            current_hash: Some(sha256_hex(b"missing")),
            repository_source: Some("fixture".to_string()),
            object_type: Some("roa".to_string()),
            state: RepositoryViewState::Present,
        })
        .expect("put view");
    let error = store
        .verify_current_repository_blobs(16)
        .expect_err("missing blob must fail");
    assert!(error.to_string().contains("missing.roa"), "{error}");
}

#[test]
fn vcir_related_artifact_reject_reason_serde_backward_compatible() {
    // Legacy cache JSON written before the reject_reason ("e") field existed.
    let legacy = r#"{"r":"manifest","k":"mft","u":"rsync://example.test/a.mft","h":"0000000000000000000000000000000000000000000000000000000000000000","t":"mft","s":"accepted"}"#;
    let artifact: VcirRelatedArtifact = serde_json::from_str(legacy).expect("legacy artifact");
    assert_eq!(artifact.reject_reason, None);

    // None reason is skipped on serialization, keeping cache entries compact.
    let json_none = serde_json::to_string(&artifact).expect("serialize none");
    assert!(!json_none.contains("\"e\""));

    // A recorded reason round-trips through the "e" field.
    let mut with_reason = artifact.clone();
    with_reason.validation_status = VcirArtifactValidationStatus::Rejected;
    with_reason.reject_reason = Some("bad object".to_string());
    let json = serde_json::to_string(&with_reason).expect("serialize");
    assert!(json.contains("\"e\":\"bad object\""));
    let decoded: VcirRelatedArtifact = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(decoded.reject_reason.as_deref(), Some("bad object"));

    // PublicationPointCacheObject conversion carries the reason both ways.
    let cache_object = PublicationPointCacheObject::from_related_artifact(&with_reason);
    assert_eq!(cache_object.reject_reason.as_deref(), Some("bad object"));
    let back = cache_object.to_related_artifact();
    assert_eq!(back.reject_reason.as_deref(), Some("bad object"));

    // Legacy PublicationPointCacheObject JSON without "e" still deserializes.
    let legacy_object: PublicationPointCacheObject =
        serde_json::from_str(legacy).expect("legacy cache object");
    assert_eq!(legacy_object.reject_reason, None);
}
