use super::*;
use crate::analysis::timing::{TimingHandle, TimingMeta};
use crate::current_repo_index::CurrentRepoIndex;
use crate::storage::RocksStore;
use std::collections::HashMap;
use std::io::Read;
use std::time::Duration;

struct MapFetcher {
    map: HashMap<String, Vec<u8>>,
}

impl Fetcher for MapFetcher {
    fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
        self.map
            .get(uri)
            .cloned()
            .ok_or_else(|| format!("not found: {uri}"))
    }
}

struct SleepyFetcher {
    inner: MapFetcher,
    sleep_uri: String,
    sleep: Duration,
}

impl Fetcher for SleepyFetcher {
    fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
        if uri == self.sleep_uri {
            std::thread::sleep(self.sleep);
        }
        self.inner.fetch(uri)
    }
}

struct WriterOnlyFetcher {
    map: HashMap<String, Vec<u8>>,
}

impl Fetcher for WriterOnlyFetcher {
    fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
        Err(format!("unexpected buffered fetch: {uri}"))
    }

    fn fetch_to_writer(&self, uri: &str, out: &mut dyn std::io::Write) -> Result<u64, String> {
        let bytes = self
            .map
            .get(uri)
            .ok_or_else(|| format!("not found: {uri}"))?;
        out.write_all(bytes)
            .map_err(|e| format!("write sink failed: {e}"))?;
        Ok(bytes.len() as u64)
    }
}

struct NonAsciiWriterFetcher;

impl Fetcher for NonAsciiWriterFetcher {
    fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
        Err(format!("unexpected buffered fetch: {uri}"))
    }

    fn fetch_to_writer(&self, _uri: &str, out: &mut dyn std::io::Write) -> Result<u64, String> {
        out.write_all(&[0x80])
            .map_err(|e| format!("write sink failed: {e}"))?;
        Err("snapshot body contains non-ASCII bytes".to_string())
    }
}

fn assert_current_object(store: &RocksStore, uri: &str, expected: &[u8]) {
    assert_eq!(
        store
            .load_current_object_bytes_by_uri(uri)
            .expect("load current object"),
        Some(expected.to_vec())
    );
}

fn notification_xml(
    session_id: &str,
    serial: u64,
    snapshot_uri: &str,
    snapshot_hash: &str,
) -> Vec<u8> {
    format!(
            r#"<notification xmlns="{RRDP_XMLNS}" version="1" session_id="{session_id}" serial="{serial}"><snapshot uri="{snapshot_uri}" hash="{snapshot_hash}"/></notification>"#
        )
        .into_bytes()
}

fn notification_xml_with_deltas(
    session_id: &str,
    serial: u64,
    snapshot_uri: &str,
    snapshot_hash: &str,
    deltas: &[(&str, u64, &str, &str)],
) -> Vec<u8> {
    let mut out = format!(
        r#"<notification xmlns="{RRDP_XMLNS}" version="1" session_id="{session_id}" serial="{serial}"><snapshot uri="{snapshot_uri}" hash="{snapshot_hash}"/>"#
    );
    for (_name, delta_serial, uri, hash) in deltas {
        out.push_str(&format!(
            r#"<delta serial="{delta_serial}" uri="{uri}" hash="{hash}"/>"#
        ));
    }
    out.push_str("</notification>");
    out.into_bytes()
}

fn snapshot_xml(session_id: &str, serial: u64, published: &[(&str, &[u8])]) -> Vec<u8> {
    let mut out = format!(
        r#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{session_id}" serial="{serial}">"#
    );
    for (uri, bytes) in published {
        let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
        out.push_str(&format!(r#"<publish uri="{uri}">{b64}</publish>"#));
    }
    out.push_str("</snapshot>");
    out.into_bytes()
}

#[test]
fn fetch_snapshot_into_tempfile_streams_and_validates_hash() {
    let snapshot_uri = "https://example.test/snapshot.xml";
    let snapshot = b"<snapshot xmlns=\"http://www.ripe.net/rpki/rrdp\" version=\"1\"/>".to_vec();
    let mut expected_hash = [0u8; 32];
    expected_hash.copy_from_slice(&sha2::Sha256::digest(&snapshot));
    let fetcher = WriterOnlyFetcher {
        map: HashMap::from([(snapshot_uri.to_string(), snapshot.clone())]),
    };

    let (mut file, bytes_written) =
        fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &expected_hash)
            .expect("fetch snapshot into tempfile");
    assert_eq!(bytes_written, snapshot.len() as u64);
    let mut got = Vec::new();
    file.as_file_mut()
        .read_to_end(&mut got)
        .expect("read tempfile");
    assert_eq!(got, snapshot);

    let mut wrong_hash = expected_hash;
    wrong_hash[0] ^= 0xff;
    let err = fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &wrong_hash).unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::SnapshotHashMismatch)
    ));
}

#[test]
fn fetch_snapshot_into_tempfile_maps_stream_non_ascii_error() {
    let err = fetch_snapshot_into_tempfile(
        &NonAsciiWriterFetcher,
        "https://example.test/snapshot.xml",
        &[0u8; 32],
    )
    .unwrap_err();
    assert!(matches!(err, RrdpSyncError::Rrdp(RrdpError::NotAscii)));
}

#[test]
fn timing_rrdp_repo_step_spans_cover_snapshot_fetch_duration() {
    let temp = tempfile::tempdir().expect("tempdir");
    let store_dir = temp.path().join("db");
    let store = RocksStore::open(&store_dir).expect("open rocksdb");

    let notification_uri = "https://example.test/notification.xml";
    let snapshot_uri = "https://example.test/snapshot.xml";
    let published_uri = "rsync://example.test/repo/a.mft";
    let published_bytes = b"x";
    let session_id = "550e8400-e29b-41d4-a716-446655440000";

    let snapshot = snapshot_xml(session_id, 1, &[(published_uri, published_bytes)]);
    let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
    let notif = notification_xml(session_id, 1, snapshot_uri, &snapshot_hash);

    let mut map = HashMap::new();
    map.insert(snapshot_uri.to_string(), snapshot);
    let fetcher = SleepyFetcher {
        inner: MapFetcher { map },
        sleep_uri: snapshot_uri.to_string(),
        sleep: Duration::from_millis(25),
    };

    let timing = TimingHandle::new(TimingMeta {
        recorded_at_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
        validation_time_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
        tal_url: None,
        db_path: Some(store_dir.to_string_lossy().into_owned()),
    });

    sync_from_notification_snapshot_with_timing(
        &store,
        notification_uri,
        &notif,
        &fetcher,
        Some(&timing),
    )
    .expect("rrdp snapshot sync ok");

    let timing_path = temp.path().join("timing.json");
    timing.write_json(&timing_path, 200).expect("write timing");
    let rep: serde_json::Value =
        serde_json::from_slice(&std::fs::read(&timing_path).expect("read timing"))
            .expect("parse timing");

    let want = format!("{notification_uri}::fetch_snapshot");
    let steps = rep
        .get("top_rrdp_repo_steps")
        .and_then(|v| v.as_array())
        .expect("top_rrdp_repo_steps array");
    let entry = steps
        .iter()
        .find(|e| e.get("key").and_then(|k| k.as_str()) == Some(want.as_str()))
        .unwrap_or_else(|| panic!("missing timing step entry for {want}"));
    let nanos = entry
        .get("total_nanos")
        .and_then(|v| v.as_u64())
        .expect("total_nanos");
    assert!(
        nanos >= 20_000_000,
        "expected fetch_snapshot timing to include the fetch duration; got {nanos}ns"
    );
}

#[test]
fn parse_notification_snapshot_rejects_non_ascii() {
    let mut xml = b"<notification/>".to_vec();
    xml.push(0x80);
    let err = parse_notification_snapshot(&xml).unwrap_err();
    assert!(matches!(err, RrdpError::NotAscii));
}

#[test]
fn parse_notification_snapshot_parses_valid_minimal_notification() {
    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let snapshot_uri = "https://example.net/snapshot.xml";
    let hash = "00".repeat(32);
    let xml = notification_xml(sid, 7, snapshot_uri, &hash);
    let n = parse_notification_snapshot(&xml).expect("parse");
    assert_eq!(n.session_id, Uuid::parse_str(sid).unwrap());
    assert_eq!(n.serial, 7);
    assert_eq!(n.snapshot_uri, snapshot_uri);
    assert_eq!(hex::encode(n.snapshot_hash_sha256), hash);
}

#[test]
fn parse_notification_parses_deltas_and_validates_contiguity() {
    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let snapshot_uri = "https://example.net/snapshot.xml";
    let hash = "00".repeat(32);
    let d_hash_2 = "11".repeat(32);
    let d_hash_3 = "22".repeat(32);
    // Provide deltas in reverse order to ensure we sort.
    let xml = notification_xml_with_deltas(
        sid,
        3,
        snapshot_uri,
        &hash,
        &[
            ("d3", 3, "https://example.net/delta-3.xml", &d_hash_3),
            ("d2", 2, "https://example.net/delta-2.xml", &d_hash_2),
        ],
    );
    let n = parse_notification(&xml).expect("parse notification");
    assert_eq!(n.serial, 3);
    assert_eq!(n.deltas.len(), 2);
    assert_eq!(n.deltas[0].serial, 2);
    assert_eq!(n.deltas[1].serial, 3);
    assert_eq!(n.deltas[0].uri, "https://example.net/delta-2.xml");
    assert_eq!(hex::encode(n.deltas[1].hash_sha256), d_hash_3);
}

#[test]
fn parse_notification_rejects_non_contiguous_deltas() {
    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let snapshot_uri = "https://example.net/snapshot.xml";
    let hash = "00".repeat(32);
    let d_hash_1 = "11".repeat(32);
    let d_hash_3 = "22".repeat(32);
    // Missing delta serial 2.
    let xml = notification_xml_with_deltas(
        sid,
        3,
        snapshot_uri,
        &hash,
        &[
            ("d3", 3, "https://example.net/delta-3.xml", &d_hash_3),
            ("d1", 1, "https://example.net/delta-1.xml", &d_hash_1),
        ],
    );
    let err = parse_notification(&xml).unwrap_err();
    assert!(matches!(err, RrdpError::DeltaRefChainNotContiguous { .. }));
}

fn delta_xml(session_id: &str, serial: u64, elements: &[&str]) -> Vec<u8> {
    let mut out = format!(
        r#"<delta xmlns="{RRDP_XMLNS}" version="1" session_id="{session_id}" serial="{serial}">"#
    );
    for e in elements {
        out.push_str(e);
    }
    out.push_str("</delta>");
    out.into_bytes()
}

#[test]
fn parse_delta_file_parses_publish_and_withdraw() {
    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let serial = 3u64;
    let publish_bytes = b"abc";
    let publish_b64 = base64::engine::general_purpose::STANDARD.encode(publish_bytes);
    let withdraw_hash = "33".repeat(32);

    let xml = delta_xml(
        sid,
        serial,
        &[
            &format!(r#"<publish uri="rsync://example.net/repo/a.mft">{publish_b64}</publish>"#),
            &format!(r#"<withdraw uri="rsync://example.net/repo/b.cer" hash="{withdraw_hash}"/>"#),
        ],
    );

    let d = parse_delta_file(&xml).expect("parse delta");
    assert_eq!(d.session_id, Uuid::parse_str(sid).unwrap());
    assert_eq!(d.serial, serial);
    assert_eq!(d.elements.len(), 2);
    match &d.elements[0] {
        DeltaElement::Publish {
            uri,
            hash_sha256,
            bytes,
        } => {
            assert_eq!(uri, "rsync://example.net/repo/a.mft");
            assert_eq!(*hash_sha256, None);
            assert_eq!(bytes, publish_bytes);
        }
        _ => panic!("expected publish"),
    }
    match &d.elements[1] {
        DeltaElement::Withdraw { uri, hash_sha256 } => {
            assert_eq!(uri, "rsync://example.net/repo/b.cer");
            assert_eq!(hex::encode(hash_sha256), withdraw_hash);
        }
        _ => panic!("expected withdraw"),
    }
}

#[test]
fn parse_delta_file_rejects_withdraw_with_content() {
    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let serial = 1u64;
    let withdraw_hash = "33".repeat(32);
    let xml = delta_xml(
        sid,
        serial,
        &[&format!(
            r#"<withdraw uri="rsync://example.net/repo/b.cer" hash="{withdraw_hash}">AA==</withdraw>"#
        )],
    );
    let err = parse_delta_file(&xml).unwrap_err();
    assert!(matches!(err, RrdpError::DeltaWithdrawUnexpectedContent));
}

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

    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let notif_uri = "https://example.net/notification.xml";

    // Start from snapshot state with a + b
    let snapshot_uri = "https://example.net/snapshot.xml";
    let snapshot = snapshot_xml(
        sid,
        1,
        &[
            ("rsync://example.net/repo/a.mft", b"a1"),
            ("rsync://example.net/repo/b.roa", b"b1"),
        ],
    );
    let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
    let notif = notification_xml(sid, 1, snapshot_uri, &snapshot_hash);
    let fetcher = MapFetcher {
        map: HashMap::from([(snapshot_uri.to_string(), snapshot)]),
    };
    sync_from_notification_snapshot(&store, notif_uri, &notif, &fetcher).expect("sync snapshot");

    let old_b = store
        .load_current_object_bytes_by_uri("rsync://example.net/repo/b.roa")
        .expect("load current b")
        .expect("b present");
    let old_b_hash = hex::encode(sha2::Sha256::digest(old_b.as_slice()));

    let withdraw_a_hash = hex::encode(sha2::Sha256::digest(b"a1".as_slice()));
    let publish_c_b64 = base64::engine::general_purpose::STANDARD.encode(b"c2");
    let replace_b_b64 = base64::engine::general_purpose::STANDARD.encode(b"b2");

    let delta = delta_xml(
        sid,
        2,
        &[
            &format!(
                r#"<withdraw uri="rsync://example.net/repo/a.mft" hash="{withdraw_a_hash}"/>"#
            ),
            &format!(
                r#"<publish uri="rsync://example.net/repo/b.roa" hash="{old_b_hash}">{replace_b_b64}</publish>"#
            ),
            &format!(r#"<publish uri="rsync://example.net/repo/c.crl">{publish_c_b64}</publish>"#),
        ],
    );
    let delta_hash = sha2::Sha256::digest(&delta);
    let mut expected_hash = [0u8; 32];
    expected_hash.copy_from_slice(delta_hash.as_slice());

    let applied = apply_delta(
        &store,
        notif_uri,
        None,
        &delta,
        expected_hash,
        Uuid::parse_str(sid).unwrap(),
        2,
    )
    .expect("apply delta");
    assert_eq!(applied, 3);

    assert_eq!(
        store
            .load_current_object_bytes_by_uri("rsync://example.net/repo/a.mft")
            .expect("load current a"),
        None,
        "a withdrawn"
    );
    let b = store
        .load_current_object_bytes_by_uri("rsync://example.net/repo/b.roa")
        .expect("load current b")
        .expect("b present");
    assert_eq!(b, b"b2");
    let c = store
        .load_current_object_bytes_by_uri("rsync://example.net/repo/c.crl")
        .expect("load current c")
        .expect("c present");
    assert_eq!(c, b"c2");

    assert!(
        !store
            .is_current_rrdp_source_member(notif_uri, "rsync://example.net/repo/a.mft")
            .expect("is member"),
        "a removed from rrdp repo index"
    );
    assert!(
        store
            .is_current_rrdp_source_member(notif_uri, "rsync://example.net/repo/c.crl")
            .expect("is member"),
        "c added to rrdp repo index"
    );

    let a_view = store
        .get_repository_view_entry("rsync://example.net/repo/a.mft")
        .expect("get a view")
        .expect("a view exists");
    assert_eq!(a_view.state, crate::storage::RepositoryViewState::Withdrawn);
    let b_view = store
        .get_repository_view_entry("rsync://example.net/repo/b.roa")
        .expect("get b view")
        .expect("b view exists");
    assert_eq!(b_view.state, crate::storage::RepositoryViewState::Present);
    assert_eq!(
        b_view.current_hash.as_deref(),
        Some(hex::encode(sha2::Sha256::digest(b"b2")).as_str())
    );
    let c_owner = store
        .get_rrdp_uri_owner_record("rsync://example.net/repo/c.crl")
        .expect("get c owner")
        .expect("c owner exists");
    assert_eq!(
        c_owner.owner_state,
        crate::storage::RrdpUriOwnerState::Active
    );
    let a_member = store
        .get_rrdp_source_member_record(notif_uri, "rsync://example.net/repo/a.mft")
        .expect("get a member")
        .expect("a member exists");
    assert!(!a_member.present);
    let current_members = store
        .list_current_rrdp_source_members(notif_uri)
        .expect("list current members");
    assert_eq!(
        current_members
            .iter()
            .map(|record| record.rsync_uri.as_str())
            .collect::<Vec<_>>(),
        vec![
            "rsync://example.net/repo/b.roa",
            "rsync://example.net/repo/c.crl",
        ]
    );
}

#[test]
fn apply_delta_rejects_hash_mismatch() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let store = RocksStore::open(tmp.path()).expect("open rocksdb");
    let sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
    let notif_uri = "https://example.net/notification.xml";

    let delta = delta_xml(
        sid.to_string().as_str(),
        1,
        &[r#"<publish uri="rsync://example.net/repo/a.mft">QQ==</publish>"#],
    );
    let mut wrong = [0u8; 32];
    wrong[0] = 1;
    let err = apply_delta(&store, notif_uri, None, &delta, wrong, sid, 1).unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::DeltaHashMismatch)
    ));
}

#[test]
fn apply_delta_rejects_withdraw_of_non_member() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let store = RocksStore::open(tmp.path()).expect("open rocksdb");
    let sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
    let notif_uri = "https://example.net/notification.xml";

    let withdraw_hash = "00".repeat(32);
    let delta = delta_xml(
        sid.to_string().as_str(),
        1,
        &[&format!(
            r#"<withdraw uri="rsync://example.net/repo/a.mft" hash="{withdraw_hash}"/>"#
        )],
    );
    let delta_hash = sha2::Sha256::digest(&delta);
    let mut expected_hash = [0u8; 32];
    expected_hash.copy_from_slice(delta_hash.as_slice());

    let err = apply_delta(&store, notif_uri, None, &delta, expected_hash, sid, 1).unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::DeltaTargetNotFromRepository { .. })
    ));
}

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

    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let notif_uri = "https://example.net/notification.xml";

    // Seed snapshot with a.mft.
    let snapshot_uri = "https://example.net/snapshot.xml";
    let snapshot = snapshot_xml(sid, 1, &[("rsync://example.net/repo/a.mft", b"a1")]);
    let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
    let notif = notification_xml(sid, 1, snapshot_uri, &snapshot_hash);
    let fetcher = MapFetcher {
        map: HashMap::from([(snapshot_uri.to_string(), snapshot)]),
    };
    sync_from_notification_snapshot(&store, notif_uri, &notif, &fetcher).expect("seed");

    // Replace publish for an existing URI must have @hash.
    let publish_b64 = base64::engine::general_purpose::STANDARD.encode(b"a2");
    let delta = delta_xml(
        sid,
        2,
        &[&format!(
            r#"<publish uri="rsync://example.net/repo/a.mft">{publish_b64}</publish>"#
        )],
    );
    let delta_hash = sha2::Sha256::digest(&delta);
    let mut expected_hash = [0u8; 32];
    expected_hash.copy_from_slice(delta_hash.as_slice());

    let err = apply_delta(
        &store,
        notif_uri,
        None,
        &delta,
        expected_hash,
        Uuid::parse_str(sid).unwrap(),
        2,
    )
    .unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::DeltaPublishWithoutHashForExisting { .. })
    ));
}

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

    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let notif_uri = "https://example.net/notification.xml";

    // Seed snapshot with a.mft.
    let snapshot_uri = "https://example.net/snapshot.xml";
    let snapshot = snapshot_xml(sid, 1, &[("rsync://example.net/repo/a.mft", b"a1")]);
    let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
    let notif = notification_xml(sid, 1, snapshot_uri, &snapshot_hash);
    let fetcher = MapFetcher {
        map: HashMap::from([(snapshot_uri.to_string(), snapshot)]),
    };
    sync_from_notification_snapshot(&store, notif_uri, &notif, &fetcher).expect("seed");

    let old_bytes = store
        .load_current_object_bytes_by_uri("rsync://example.net/repo/a.mft")
        .expect("get")
        .expect("present");
    let old_hash = hex::encode(sha2::Sha256::digest(old_bytes.as_slice()));

    // Hash mismatch on withdraw.
    let wrong_hash = "11".repeat(32);
    let delta = delta_xml(
        sid,
        2,
        &[&format!(
            r#"<withdraw uri="rsync://example.net/repo/a.mft" hash="{wrong_hash}"/>"#
        )],
    );
    let delta_hash = sha2::Sha256::digest(&delta);
    let mut expected_hash = [0u8; 32];
    expected_hash.copy_from_slice(delta_hash.as_slice());
    let err = apply_delta(
        &store,
        notif_uri,
        None,
        &delta,
        expected_hash,
        Uuid::parse_str(sid).unwrap(),
        2,
    )
    .unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::DeltaTargetHashMismatch { .. })
    ));

    // Target missing in local cache (index still says it's a member).
    store
        .delete_repository_view_entry("rsync://example.net/repo/a.mft")
        .expect("delete current repository view entry");
    let delta = delta_xml(
        sid,
        2,
        &[&format!(
            r#"<withdraw uri="rsync://example.net/repo/a.mft" hash="{old_hash}"/>"#
        )],
    );
    let delta_hash = sha2::Sha256::digest(&delta);
    let mut expected_hash = [0u8; 32];
    expected_hash.copy_from_slice(delta_hash.as_slice());
    let err = apply_delta(
        &store,
        notif_uri,
        None,
        &delta,
        expected_hash,
        Uuid::parse_str(sid).unwrap(),
        2,
    )
    .unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::DeltaTargetMissing { .. })
    ));
}

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

    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let notif_uri = "https://example.net/notification.xml";

    let publish_b64 = base64::engine::general_purpose::STANDARD.encode(b"x");
    let delta = delta_xml(
        sid,
        2,
        &[&format!(
            r#"<publish uri="rsync://example.net/repo/x.cer">{publish_b64}</publish>"#
        )],
    );
    let delta_hash = sha2::Sha256::digest(&delta);
    let mut expected_hash = [0u8; 32];
    expected_hash.copy_from_slice(delta_hash.as_slice());

    // Session mismatch.
    let other_sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
    let err =
        apply_delta(&store, notif_uri, None, &delta, expected_hash, other_sid, 2).unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::DeltaSessionIdMismatch { .. })
    ));

    // Serial mismatch.
    let err = apply_delta(
        &store,
        notif_uri,
        None,
        &delta,
        expected_hash,
        Uuid::parse_str(sid).unwrap(),
        3,
    )
    .unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::DeltaSerialMismatch { .. })
    ));
}

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

    let sid_a = "550e8400-e29b-41d4-a716-446655440000";
    let sid_b = "550e8400-e29b-41d4-a716-446655440001";
    let uri = "rsync://example.net/repo/a.mft";

    let notif_a_uri = "https://example.net/a/notification.xml";
    let snapshot_a_uri = "https://example.net/a/snapshot.xml";
    let snapshot_a = snapshot_xml(sid_a, 1, &[(uri, b"a1")]);
    let snapshot_a_hash = hex::encode(sha2::Sha256::digest(&snapshot_a));
    let notif_a = notification_xml(sid_a, 1, snapshot_a_uri, &snapshot_a_hash);
    let fetcher_a = MapFetcher {
        map: HashMap::from([(snapshot_a_uri.to_string(), snapshot_a)]),
    };
    sync_from_notification_snapshot(&store, notif_a_uri, &notif_a, &fetcher_a)
        .expect("seed source a");

    let notif_b_uri = "https://example.net/b/notification.xml";
    let snapshot_b_uri = "https://example.net/b/snapshot.xml";
    let snapshot_b = snapshot_xml(sid_b, 1, &[(uri, b"b1")]);
    let snapshot_b_hash = hex::encode(sha2::Sha256::digest(&snapshot_b));
    let notif_b = notification_xml(sid_b, 1, snapshot_b_uri, &snapshot_b_hash);
    let fetcher_b = MapFetcher {
        map: HashMap::from([(snapshot_b_uri.to_string(), snapshot_b)]),
    };

    let err = sync_from_notification_snapshot(&store, notif_b_uri, &notif_b, &fetcher_b)
        .expect_err("cross-source overwrite must fail");
    assert!(matches!(err, RrdpSyncError::Storage(_)));
    assert!(err.to_string().contains("owner conflict"), "{err}");
}

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

    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let serial = 9u64;
    let notif_uri = "https://example.net/notification.xml";
    let snapshot_uri = "https://example.net/snapshot.xml";

    let snapshot = snapshot_xml(
        sid,
        serial,
        &[
            ("rsync://example.net/repo/a.mft", b"mft-bytes"),
            ("rsync://example.net/repo/b.roa", b"roa-bytes"),
        ],
    );
    let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
    let notif = notification_xml(sid, serial, snapshot_uri, &snapshot_hash);

    let fetcher = MapFetcher {
        map: HashMap::from([(snapshot_uri.to_string(), snapshot.clone())]),
    };

    let published =
        sync_from_notification_snapshot(&store, notif_uri, &notif, &fetcher).expect("sync");
    assert_eq!(published, 2);

    assert_current_object(&store, "rsync://example.net/repo/a.mft", b"mft-bytes");
    assert_current_object(&store, "rsync://example.net/repo/b.roa", b"roa-bytes");

    let state = load_rrdp_local_state(&store, notif_uri)
        .expect("get rrdp state")
        .expect("state present");
    assert_eq!(state.session_id, sid);
    assert_eq!(state.serial, serial);

    let source = store
        .get_rrdp_source_record(notif_uri)
        .expect("get rrdp source")
        .expect("rrdp source exists");
    assert_eq!(source.last_session_id.as_deref(), Some(sid));
    assert_eq!(source.last_serial, Some(serial));
    assert_eq!(
        source.sync_state,
        crate::storage::RrdpSourceSyncState::SnapshotOnly
    );

    let view = store
        .get_repository_view_entry("rsync://example.net/repo/a.mft")
        .expect("get repository view")
        .expect("repository view exists");
    assert_eq!(view.state, crate::storage::RepositoryViewState::Present);
    assert_eq!(view.repository_source.as_deref(), Some(notif_uri));

    let current_bytes = store
        .load_current_object_bytes_by_uri("rsync://example.net/repo/a.mft")
        .expect("load current bytes")
        .expect("current object bytes exist");
    assert_eq!(current_bytes, b"mft-bytes".to_vec());
    assert!(
        store
            .get_raw_by_hash_entry(hex::encode(sha2::Sha256::digest(b"mft-bytes")).as_str())
            .expect("get raw_by_hash")
            .is_none()
    );

    let member = store
        .get_rrdp_source_member_record(notif_uri, "rsync://example.net/repo/a.mft")
        .expect("get member")
        .expect("member exists");
    assert!(member.present);
    let owner = store
        .get_rrdp_uri_owner_record("rsync://example.net/repo/a.mft")
        .expect("get owner")
        .expect("owner exists");
    assert_eq!(owner.notify_uri, notif_uri);
    assert_eq!(owner.owner_state, crate::storage::RrdpUriOwnerState::Active);
}

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

    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let notif_uri = "https://example.net/notification.xml";

    // serial 1: publish a + b
    let snapshot_uri_1 = "https://example.net/snapshot-1.xml";
    let snapshot_1 = snapshot_xml(
        sid,
        1,
        &[
            ("rsync://example.net/repo/a.mft", b"a1"),
            ("rsync://example.net/repo/b.roa", b"b1"),
        ],
    );
    let snapshot_hash_1 = hex::encode(sha2::Sha256::digest(&snapshot_1));
    let notif_1 = notification_xml(sid, 1, snapshot_uri_1, &snapshot_hash_1);

    let fetcher_1 = MapFetcher {
        map: HashMap::from([(snapshot_uri_1.to_string(), snapshot_1)]),
    };
    sync_from_notification_snapshot(&store, notif_uri, &notif_1, &fetcher_1).expect("sync 1");

    // serial 2: publish b (new bytes) + c, and drop a
    let snapshot_uri_2 = "https://example.net/snapshot-2.xml";
    let snapshot_2 = snapshot_xml(
        sid,
        2,
        &[
            ("rsync://example.net/repo/b.roa", b"b2"),
            ("rsync://example.net/repo/c.crl", b"c2"),
        ],
    );
    let snapshot_hash_2 = hex::encode(sha2::Sha256::digest(&snapshot_2));
    let notif_2 = notification_xml(sid, 2, snapshot_uri_2, &snapshot_hash_2);

    let fetcher_2 = MapFetcher {
        map: HashMap::from([(snapshot_uri_2.to_string(), snapshot_2)]),
    };
    sync_from_notification_snapshot(&store, notif_uri, &notif_2, &fetcher_2).expect("sync 2");

    assert!(
        store
            .load_current_object_bytes_by_uri("rsync://example.net/repo/a.mft")
            .expect("get current a")
            .is_none(),
        "a should be deleted by full-state snapshot apply"
    );

    assert_current_object(&store, "rsync://example.net/repo/b.roa", b"b2");
    assert_current_object(&store, "rsync://example.net/repo/c.crl", b"c2");
}

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

    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let notif_uri = "https://example.net/notification.xml";

    // Seed state with snapshot serial=1 containing a+b.
    let snapshot_uri_1 = "https://example.net/snapshot-1.xml";
    let snapshot_1 = snapshot_xml(
        sid,
        1,
        &[
            ("rsync://example.net/repo/a.mft", b"a1"),
            ("rsync://example.net/repo/b.roa", b"b1"),
        ],
    );
    let snapshot_hash_1 = hex::encode(sha2::Sha256::digest(&snapshot_1));
    let notif_1 = notification_xml(sid, 1, snapshot_uri_1, &snapshot_hash_1);
    let fetcher_1 = MapFetcher {
        map: HashMap::from([(snapshot_uri_1.to_string(), snapshot_1)]),
    };
    sync_from_notification_snapshot(&store, notif_uri, &notif_1, &fetcher_1).expect("seed");

    // Notification serial=3 with deltas 2 and 3. Snapshot URI is intentionally not fetchable
    // to assert we really use deltas.
    let snapshot_uri_3 = "https://example.net/snapshot-3.xml";
    let snapshot_hash_3 = "00".repeat(32);

    let publish_c_b64 = base64::engine::general_purpose::STANDARD.encode(b"c2");
    let delta_2 = delta_xml(
        sid,
        2,
        &[&format!(
            r#"<publish uri="rsync://example.net/repo/c.crl">{publish_c_b64}</publish>"#
        )],
    );
    let delta_2_hash_hex = hex::encode(sha2::Sha256::digest(&delta_2));

    let c_hash_hex = hex::encode(sha2::Sha256::digest(b"c2".as_slice()));
    let delta_3 = delta_xml(
        sid,
        3,
        &[&format!(
            r#"<withdraw uri="rsync://example.net/repo/c.crl" hash="{c_hash_hex}"/>"#
        )],
    );
    let delta_3_hash_hex = hex::encode(sha2::Sha256::digest(&delta_3));

    let notif_3 = notification_xml_with_deltas(
        sid,
        3,
        snapshot_uri_3,
        &snapshot_hash_3,
        &[
            (
                "d3",
                3,
                "https://example.net/delta-3.xml",
                &delta_3_hash_hex,
            ),
            (
                "d2",
                2,
                "https://example.net/delta-2.xml",
                &delta_2_hash_hex,
            ),
        ],
    );

    let fetcher = MapFetcher {
        map: HashMap::from([
            ("https://example.net/delta-2.xml".to_string(), delta_2),
            ("https://example.net/delta-3.xml".to_string(), delta_3),
        ]),
    };

    let applied = sync_from_notification(&store, notif_uri, &notif_3, &fetcher).expect("sync");
    assert!(applied > 0);

    // Delta 2 publishes c then delta 3 withdraws it => final state should not contain c.
    assert!(
        store
            .load_current_object_bytes_by_uri("rsync://example.net/repo/c.crl")
            .expect("get current")
            .is_none()
    );

    let state = load_rrdp_local_state(&store, notif_uri)
        .expect("get rrdp state")
        .expect("state present");
    assert_eq!(state.session_id, Uuid::parse_str(sid).unwrap().to_string());
    assert_eq!(state.serial, 3);
}

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

    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let notif_uri = "https://example.net/notification.xml";
    let snapshot_uri = "https://example.net/snapshot.xml";
    let uri_a = "rsync://example.net/repo/a.mft";
    let uri_b = "rsync://example.net/repo/b.roa";

    let snapshot = snapshot_xml(sid, 1, &[(uri_a, b"a1"), (uri_b, b"b1")]);
    let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
    let notif = notification_xml(sid, 1, snapshot_uri, &snapshot_hash);
    let fetcher_1 = MapFetcher {
        map: HashMap::from([(snapshot_uri.to_string(), snapshot)]),
    };
    sync_from_notification_snapshot(&store, notif_uri, &notif, &fetcher_1).expect("seed");

    let index = CurrentRepoIndex::shared();
    let no_fetcher = MapFetcher {
        map: HashMap::new(),
    };
    let applied = sync_from_notification_with_timing_and_download_log(
        &store,
        notif_uri,
        Some(&index),
        &notif,
        &no_fetcher,
        None,
        None,
    )
    .expect("same serial no-op");
    assert_eq!(applied, 0);

    let index = index.read().expect("read-lock index");
    assert_eq!(index.active_uri_count(), 2);
    assert!(index.get_by_uri(uri_a).is_some());
    assert!(index.get_by_uri(uri_b).is_some());
}

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

    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let notif_uri = "https://example.net/notification.xml";
    let snapshot_uri_1 = "https://example.net/snapshot-1.xml";
    let uri_a = "rsync://example.net/repo/a.mft";
    let uri_b = "rsync://example.net/repo/b.roa";
    let uri_c = "rsync://example.net/repo/c.crl";

    let snapshot_1 = snapshot_xml(sid, 1, &[(uri_a, b"a1"), (uri_b, b"b1")]);
    let snapshot_hash_1 = hex::encode(sha2::Sha256::digest(&snapshot_1));
    let notif_1 = notification_xml(sid, 1, snapshot_uri_1, &snapshot_hash_1);
    let fetcher_1 = MapFetcher {
        map: HashMap::from([(snapshot_uri_1.to_string(), snapshot_1)]),
    };
    sync_from_notification_snapshot(&store, notif_uri, &notif_1, &fetcher_1).expect("seed");

    let publish_c_b64 = base64::engine::general_purpose::STANDARD.encode(b"c2");
    let delta_2 = delta_xml(
        sid,
        2,
        &[&format!(
            r#"<publish uri="{uri_c}">{publish_c_b64}</publish>"#
        )],
    );
    let delta_2_hash_hex = hex::encode(sha2::Sha256::digest(&delta_2));
    let notif_2 = notification_xml_with_deltas(
        sid,
        2,
        "https://example.net/snapshot-2.xml",
        &"00".repeat(32),
        &[(
            "d2",
            2,
            "https://example.net/delta-2.xml",
            &delta_2_hash_hex,
        )],
    );
    let fetcher_2 = MapFetcher {
        map: HashMap::from([("https://example.net/delta-2.xml".to_string(), delta_2)]),
    };

    let index = CurrentRepoIndex::shared();
    let applied = sync_from_notification_with_timing_and_download_log(
        &store,
        notif_uri,
        Some(&index),
        &notif_2,
        &fetcher_2,
        None,
        None,
    )
    .expect("delta sync");
    assert_eq!(applied, 1);

    let index = index.read().expect("read-lock index");
    assert_eq!(index.active_uri_count(), 3);
    assert!(
        index.get_by_uri(uri_a).is_some(),
        "unchanged object from the previous serial must be visible"
    );
    assert!(
        index.get_by_uri(uri_b).is_some(),
        "unchanged object from the previous serial must be visible"
    );
    assert!(index.get_by_uri(uri_c).is_some(), "delta publish visible");
}

#[test]
fn load_rrdp_local_state_uses_source_record_only() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let store = RocksStore::open(tmp.path()).expect("open rocksdb");
    let notif_uri = "https://example.net/notification.xml";
    assert_eq!(
        load_rrdp_local_state(&store, notif_uri).expect("load empty"),
        None
    );

    update_rrdp_source_record_on_success(
        &store,
        notif_uri,
        "source-session",
        9,
        crate::storage::RrdpSourceSyncState::DeltaReady,
        Some("https://example.net/snapshot.xml"),
        Some(&hex::encode([0x11; 32])),
    )
    .expect("write source record");

    let got = load_rrdp_local_state(&store, notif_uri)
        .expect("load source preferred")
        .expect("source present");
    assert_eq!(
        got,
        RrdpState {
            session_id: "source-session".to_string(),
            serial: 9,
        }
    );
}

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

    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let notif_uri = "https://example.net/notification.xml";

    // Seed state serial=1 with a only.
    let snapshot_uri_1 = "https://example.net/snapshot-1.xml";
    let snapshot_1 = snapshot_xml(sid, 1, &[("rsync://example.net/repo/a.mft", b"a1")]);
    let snapshot_hash_1 = hex::encode(sha2::Sha256::digest(&snapshot_1));
    let notif_1 = notification_xml(sid, 1, snapshot_uri_1, &snapshot_hash_1);
    let fetcher_1 = MapFetcher {
        map: HashMap::from([(snapshot_uri_1.to_string(), snapshot_1)]),
    };
    sync_from_notification_snapshot(&store, notif_uri, &notif_1, &fetcher_1).expect("seed");

    // Notification serial=3 only includes delta serial=3 (contiguous per RFC, but missing
    // serial=2 relative to our local state, so we must use snapshot).
    let snapshot_uri_3 = "https://example.net/snapshot-3.xml";
    let snapshot_3 = snapshot_xml(sid, 3, &[("rsync://example.net/repo/z.roa", b"z3")]);
    let snapshot_hash_3 = hex::encode(sha2::Sha256::digest(&snapshot_3));
    let delta_3_hash_hex = "11".repeat(32);
    let notif_3 = notification_xml_with_deltas(
        sid,
        3,
        snapshot_uri_3,
        &snapshot_hash_3,
        &[(
            "d3",
            3,
            "https://example.net/delta-3.xml",
            &delta_3_hash_hex,
        )],
    );

    let fetcher = MapFetcher {
        map: HashMap::from([(snapshot_uri_3.to_string(), snapshot_3)]),
    };

    let published = sync_from_notification(&store, notif_uri, &notif_3, &fetcher).expect("sync");
    assert_eq!(published, 1);
    assert!(
        store
            .load_current_object_bytes_by_uri("rsync://example.net/repo/z.roa")
            .expect("get current")
            .is_some()
    );
}

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

    let sid = "550e8400-e29b-41d4-a716-446655440000";
    let serial = 1u64;
    let notif_uri = "https://example.net/notification.xml";
    let snapshot_uri = "https://example.net/snapshot.xml";

    let snapshot = snapshot_xml(sid, serial, &[("rsync://example.net/repo/a.mft", b"x")]);
    let notif = notification_xml(sid, serial, snapshot_uri, &"00".repeat(32));

    let fetcher = MapFetcher {
        map: HashMap::from([(snapshot_uri.to_string(), snapshot)]),
    };
    let err = sync_from_notification_snapshot(&store, notif_uri, &notif, &fetcher).unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::SnapshotHashMismatch)
    ));
}

#[test]
fn apply_snapshot_rejects_session_id_and_serial_mismatch() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let store = RocksStore::open(tmp.path()).expect("open rocksdb");
    let notif_uri = "https://example.net/notification.xml";

    let expected_sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
    let got_sid = "550e8400-e29b-41d4-a716-446655440001";

    let snapshot = snapshot_xml(got_sid, 2, &[("rsync://example.net/repo/a.mft", b"x")]);
    let err = apply_snapshot(&store, notif_uri, None, &snapshot, expected_sid, 2).unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::SnapshotSessionIdMismatch { .. })
    ));

    let snapshot = snapshot_xml(
        expected_sid.to_string().as_str(),
        3,
        &[("rsync://example.net/repo/a.mft", b"x")],
    );
    let err = apply_snapshot(&store, notif_uri, None, &snapshot, expected_sid, 2).unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::SnapshotSerialMismatch { .. })
    ));
}

#[test]
fn strip_all_ascii_whitespace_removes_newlines_and_spaces() {
    assert_eq!(strip_all_ascii_whitespace(" a \n b\tc "), "abc");
}

#[test]
fn apply_snapshot_reports_publish_errors() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let store = RocksStore::open(tmp.path()).expect("open rocksdb");
    let notif_uri = "https://example.net/notification.xml";
    let sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();

    // Missing publish/@uri
    let xml = format!(
            r#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{sid}" serial="1"><publish>AA==</publish></snapshot>"#
        )
        .into_bytes();
    let err = apply_snapshot(&store, notif_uri, None, &xml, sid, 1).unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::PublishUriMissing)
    ));

    // Missing base64 content (no text nodes).
    let xml = format!(
            r#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{sid}" serial="1"><publish uri="rsync://example.net/repo/a.cer"></publish></snapshot>"#
        )
        .into_bytes();
    let err = apply_snapshot(&store, notif_uri, None, &xml, sid, 1).unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::PublishContentMissing)
    ));

    // Invalid base64 content.
    let xml = format!(
            r#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{sid}" serial="1"><publish uri="rsync://example.net/repo/a.cer">!!!</publish></snapshot>"#
        )
        .into_bytes();
    let err = apply_snapshot(&store, notif_uri, None, &xml, sid, 1).unwrap_err();
    assert!(matches!(
        err,
        RrdpSyncError::Rrdp(RrdpError::PublishBase64(_))
    ));
}

#[test]
fn apply_snapshot_handles_multiple_publish_batches() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let store = RocksStore::open(tmp.path()).expect("open rocksdb");
    let notif_uri = "https://example.net/notification.xml";
    let sid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();

    let total = RRDP_SNAPSHOT_APPLY_BATCH_SIZE + 7;
    let mut xml =
        format!(r#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{sid}" serial="1">"#);
    for i in 0..total {
        let uri = format!("rsync://example.net/repo/{i:04}.roa");
        let bytes = format!("payload-{i}").into_bytes();
        let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
        xml.push_str(&format!(r#"<publish uri="{uri}">{b64}</publish>"#));
    }
    xml.push_str("</snapshot>");

    let published =
        apply_snapshot(&store, notif_uri, None, xml.as_bytes(), sid, 1).expect("apply snapshot");
    assert_eq!(published, total);

    for idx in [0usize, RRDP_SNAPSHOT_APPLY_BATCH_SIZE - 1, total - 1] {
        let uri = format!("rsync://example.net/repo/{idx:04}.roa");
        let got = store
            .load_current_object_bytes_by_uri(&uri)
            .expect("load object")
            .expect("object exists");
        assert_eq!(got, format!("payload-{idx}").into_bytes());
    }
}
