use crate::analysis::timing::TimingHandle;
use crate::audit::AuditDownloadKind;
use crate::audit_downloads::DownloadLogHandle;
use crate::storage::{RocksStore, RrdpDeltaOp, RrdpSourceSyncState};
use crate::sync::store_projection::{
    build_repository_view_present_entry, build_repository_view_withdrawn_entry,
    build_rrdp_source_member_present_record, build_rrdp_source_member_withdrawn_record,
    build_rrdp_uri_owner_active_record, build_rrdp_uri_owner_withdrawn_record,
    compute_sha256_hex, current_rrdp_owner_is, ensure_rrdp_uri_can_be_owned_by,
    prepare_raw_by_hash_evidence_batch,
    put_repository_view_present, put_repository_view_withdrawn,
    put_rrdp_source_member_present, put_rrdp_source_member_withdrawn,
    put_rrdp_uri_owner_active, put_rrdp_uri_owner_withdrawn,
    update_rrdp_source_record_on_success, upsert_raw_by_hash_evidence,
};
use base64::Engine;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use uuid::Uuid;

const RRDP_XMLNS: &str = "http://www.ripe.net/rpki/rrdp";

#[derive(Debug, thiserror::Error)]
pub enum RrdpError {
    #[error("RRDP XML must be US-ASCII encoded (RFC 8182 §3.5.1.3, §3.5.2.3), got non-ASCII bytes")]
    NotAscii,

    #[error("RRDP XML parse error: {0} (RFC 8182 §3.5.1.3, §3.5.2.3)")]
    Xml(String),

    #[error(
        "RRDP root element must be <notification>, <snapshot>, or <delta>, got <{0}> (RFC 8182 §3.5.1.3, §3.5.2.3, §3.5.3.3)"
    )]
    UnexpectedRoot(String),

    #[error("RRDP XML namespace must be {RRDP_XMLNS}, got {0} (RFC 8182 §3.5.1.3, §3.5.2.3)")]
    InvalidNamespace(String),

    #[error("RRDP version must be 1, got {0} (RFC 8182 §3.5.1.3, §3.5.2.3)")]
    InvalidVersion(String),

    #[error("RRDP session_id invalid UUID: {0} (RFC 8182 §3.5.1.3)")]
    InvalidSessionId(String),

    #[error("RRDP serial invalid unsigned integer: {0} (RFC 8182 §3.5.1.3)")]
    InvalidSerial(String),

    #[error("notification must contain exactly one <snapshot> element (RFC 8182 §3.5.1.3)")]
    SnapshotCountInvalid,

    #[error("snapshot/@uri missing (RFC 8182 §3.5.1.3)")]
    SnapshotUriMissing,

    #[error("snapshot/@hash missing (RFC 8182 §3.5.1.3)")]
    SnapshotHashMissing,

    #[error("snapshot/@hash must be hex encoding of SHA-256, got {0} (RFC 8182 §3.5.1.3)")]
    SnapshotHashInvalid(String),

    #[error("delta/@serial missing in notification (RFC 8182 §3.5.1.3)")]
    DeltaRefSerialMissing,

    #[error("delta/@uri missing in notification (RFC 8182 §3.5.1.3)")]
    DeltaRefUriMissing,

    #[error("delta/@hash missing in notification (RFC 8182 §3.5.1.3)")]
    DeltaRefHashMissing,

    #[error("delta/@hash must be hex encoding of SHA-256, got {0} (RFC 8182 §3.5.1.3)")]
    DeltaRefHashInvalid(String),

    #[error("delta/@serial duplicates in notification: {0} (RFC 8182 §3.5.1.3)")]
    DeltaRefSerialDuplicate(u64),

    #[error(
        "delta/@serial must be <= notification/@serial: delta={delta_serial} notification={notification_serial} (RFC 8182 §3.5.1.3)"
    )]
    DeltaRefSerialTooHigh {
        delta_serial: u64,
        notification_serial: u64,
    },

    #[error(
        "notification contains deltas but does not end at notification/@serial: max_delta={max_delta_serial} notification={notification_serial} (RFC 8182 §3.5.1.3)"
    )]
    DeltaRefChainDoesNotEndAtNotificationSerial {
        max_delta_serial: u64,
        notification_serial: u64,
    },

    #[error(
        "notification delta chain not contiguous: missing serial={missing_serial} (range {min_serial}..{notification_serial}) (RFC 8182 §3.5.1.3)"
    )]
    DeltaRefChainNotContiguous {
        min_serial: u64,
        notification_serial: u64,
        missing_serial: u64,
    },

    #[error("snapshot file hash mismatch (RFC 8182 §3.5.1.3)")]
    SnapshotHashMismatch,

    #[error("snapshot session_id mismatch: expected {expected}, got {got} (RFC 8182 §3.5.2.3)")]
    SnapshotSessionIdMismatch { expected: String, got: String },

    #[error("snapshot serial mismatch: expected {expected}, got {got} (RFC 8182 §3.5.2.3)")]
    SnapshotSerialMismatch { expected: u64, got: u64 },

    #[error("delta file hash mismatch (RFC 8182 §3.4.2; RFC 8182 §3.5.1.3)")]
    DeltaHashMismatch,

    #[error("delta session_id mismatch: expected {expected}, got {got} (RFC 8182 §3.5.3.3)")]
    DeltaSessionIdMismatch { expected: String, got: String },

    #[error("delta serial mismatch: expected {expected}, got {got} (RFC 8182 §3.5.3.3)")]
    DeltaSerialMismatch { expected: u64, got: u64 },

    #[error("notification serial moved backwards: old={old} new={new} (RFC 8182 §3.4.1)")]
    NotificationSerialRollback { old: u64, new: u64 },

    #[error("delta publish without @hash for existing object: {rsync_uri} (RFC 8182 §3.4.2)")]
    DeltaPublishWithoutHashForExisting { rsync_uri: String },

    #[error(
        "delta withdraw/replace target not from this repository server: {rsync_uri} (RFC 8182 §3.4.2)"
    )]
    DeltaTargetNotFromRepository { rsync_uri: String },

    #[error("delta withdraw/replace target missing in local cache: {rsync_uri} (RFC 8182 §3.4.2)")]
    DeltaTargetMissing { rsync_uri: String },

    #[error("delta withdraw/replace target hash mismatch: {rsync_uri} (RFC 8182 §3.4.2)")]
    DeltaTargetHashMismatch { rsync_uri: String },

    #[error("publish/@uri missing (RFC 8182 §3.5.2.3)")]
    PublishUriMissing,

    #[error("publish element missing base64 content (RFC 8182 §3.5.2.3)")]
    PublishContentMissing,

    #[error("publish base64 decode failed (RFC 8182 §3.5.2.3): {0}")]
    PublishBase64(String),

    #[error("delta file <publish> missing @uri (RFC 8182 §3.5.3.3)")]
    DeltaPublishUriMissing,

    #[error("delta file <publish> base64 content missing (RFC 8182 §3.5.3.3)")]
    DeltaPublishContentMissing,

    #[error("delta file <publish> base64 decode failed (RFC 8182 §3.5.3.3): {0}")]
    DeltaPublishBase64(String),

    #[error(
        "delta file <publish> @hash must be hex encoding of SHA-256, got {0} (RFC 8182 §3.5.3.3)"
    )]
    DeltaPublishHashInvalid(String),

    #[error("delta file <withdraw> missing @uri (RFC 8182 §3.5.3.3)")]
    DeltaWithdrawUriMissing,

    #[error("delta file <withdraw> missing @hash (RFC 8182 §3.5.3.3)")]
    DeltaWithdrawHashMissing,

    #[error(
        "delta file <withdraw> @hash must be hex encoding of SHA-256, got {0} (RFC 8182 §3.5.3.3)"
    )]
    DeltaWithdrawHashInvalid(String),

    #[error("delta file <withdraw> must not contain text content (RFC 8182 §3.5.3.3)")]
    DeltaWithdrawUnexpectedContent,

    #[error("delta file must contain at least one publish/withdraw element (RFC 8182 §3.5.3.3)")]
    DeltaNoElements,

    #[error("delta file contains unexpected element <{0}> (RFC 8182 §3.5.3.3)")]
    DeltaUnexpectedElement(String),
}

#[derive(Debug, thiserror::Error)]
pub enum RrdpSyncError {
    #[error("{0}")]
    Rrdp(#[from] RrdpError),

    #[error("fetch failed: {0}")]
    Fetch(String),

    #[error("storage error: {0}")]
    Storage(String),
}

pub type RrdpSyncResult<T> = Result<T, RrdpSyncError>;

pub trait Fetcher {
    fn fetch(&self, uri: &str) -> Result<Vec<u8>, String>;
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RrdpState {
    pub session_id: String,
    pub serial: u64,
}

impl RrdpState {
    pub fn encode(&self) -> Result<Vec<u8>, String> {
        serde_cbor::to_vec(self).map_err(|e| e.to_string())
    }

    pub fn decode(bytes: &[u8]) -> Result<Self, String> {
        serde_cbor::from_slice(bytes).map_err(|e| e.to_string())
    }
}

pub(crate) fn load_rrdp_local_state(
    store: &RocksStore,
    notification_uri: &str,
) -> Result<Option<RrdpState>, String> {
    if let Some(record) = store
        .get_rrdp_source_record(notification_uri)
        .map_err(|e| e.to_string())?
    {
        if let (Some(session_id), Some(serial)) = (record.last_session_id, record.last_serial) {
            return Ok(Some(RrdpState { session_id, serial }));
        }
    }

    Ok(None)
}

pub(crate) fn persist_rrdp_local_state(
    store: &RocksStore,
    notification_uri: &str,
    state: &RrdpState,
    sync_state: RrdpSourceSyncState,
    last_snapshot_uri: Option<&str>,
    last_snapshot_hash_hex: Option<&str>,
) -> Result<(), String> {
    update_rrdp_source_record_on_success(
        store,
        notification_uri,
        state.session_id.as_str(),
        state.serial,
        sync_state,
        last_snapshot_uri,
        last_snapshot_hash_hex,
    )
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NotificationSnapshot {
    pub session_id: Uuid,
    pub serial: u64,
    pub snapshot_uri: String,
    pub snapshot_hash_sha256: [u8; 32],
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NotificationDeltaRef {
    pub serial: u64,
    pub uri: String,
    pub hash_sha256: [u8; 32],
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Notification {
    pub session_id: Uuid,
    pub serial: u64,
    pub snapshot_uri: String,
    pub snapshot_hash_sha256: [u8; 32],
    /// Deltas referenced by the notification file, sorted by serial ascending.
    ///
    /// If present, this list is guaranteed to be contiguous and to end at `serial`
    /// (RFC 8182 §3.5.1.3).
    pub deltas: Vec<NotificationDeltaRef>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DeltaElement {
    Publish {
        uri: String,
        hash_sha256: Option<[u8; 32]>,
        bytes: Vec<u8>,
    },
    Withdraw {
        uri: String,
        hash_sha256: [u8; 32],
    },
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeltaFile {
    pub session_id: Uuid,
    pub serial: u64,
    pub elements: Vec<DeltaElement>,
}

pub fn parse_notification(xml: &[u8]) -> Result<Notification, RrdpError> {
    let doc = parse_rrdp_xml(xml)?;
    let root = doc.root_element();
    if root.tag_name().name() != "notification" {
        return Err(RrdpError::UnexpectedRoot(
            root.tag_name().name().to_string(),
        ));
    }
    validate_root_common(&root)?;

    let session_id = parse_uuid_attr(&root, "session_id")?;
    let serial = parse_u64_attr(&root, "serial")?;

    let snapshots: Vec<_> = root
        .children()
        .filter(|n| n.is_element() && n.tag_name().name() == "snapshot")
        .collect();
    if snapshots.len() != 1 {
        return Err(RrdpError::SnapshotCountInvalid);
    }
    let snapshot = snapshots[0];

    let snapshot_uri = snapshot
        .attribute("uri")
        .ok_or(RrdpError::SnapshotUriMissing)?
        .to_string();
    let snapshot_hash_hex = snapshot
        .attribute("hash")
        .ok_or(RrdpError::SnapshotHashMissing)?;
    let snapshot_hash_sha256 = parse_sha256_hex_snapshot(snapshot_hash_hex)?;

    let mut deltas: Vec<NotificationDeltaRef> = Vec::new();
    for d in root
        .children()
        .filter(|n| n.is_element() && n.tag_name().name() == "delta")
    {
        let delta_serial = d
            .attribute("serial")
            .ok_or(RrdpError::DeltaRefSerialMissing)?;
        let delta_serial = parse_u64_str(delta_serial)?;
        if delta_serial > serial {
            return Err(RrdpError::DeltaRefSerialTooHigh {
                delta_serial,
                notification_serial: serial,
            });
        }
        let uri = d.attribute("uri").ok_or(RrdpError::DeltaRefUriMissing)?;
        let hash = d.attribute("hash").ok_or(RrdpError::DeltaRefHashMissing)?;
        let hash_sha256 = parse_sha256_hex_delta_ref(hash)?;

        deltas.push(NotificationDeltaRef {
            serial: delta_serial,
            uri: uri.to_string(),
            hash_sha256,
        });
    }

    if !deltas.is_empty() {
        let mut seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
        let mut min_serial = u64::MAX;
        let mut max_serial = 0u64;
        for d in &deltas {
            if !seen.insert(d.serial) {
                return Err(RrdpError::DeltaRefSerialDuplicate(d.serial));
            }
            min_serial = min_serial.min(d.serial);
            max_serial = max_serial.max(d.serial);
        }
        if max_serial != serial {
            return Err(RrdpError::DeltaRefChainDoesNotEndAtNotificationSerial {
                max_delta_serial: max_serial,
                notification_serial: serial,
            });
        }
        for s in min_serial..=serial {
            if !seen.contains(&s) {
                return Err(RrdpError::DeltaRefChainNotContiguous {
                    min_serial,
                    notification_serial: serial,
                    missing_serial: s,
                });
            }
        }
        deltas.sort_by_key(|d| d.serial);
    }

    Ok(Notification {
        session_id,
        serial,
        snapshot_uri,
        snapshot_hash_sha256,
        deltas,
    })
}

pub fn parse_notification_snapshot(xml: &[u8]) -> Result<NotificationSnapshot, RrdpError> {
    let n = parse_notification(xml)?;
    Ok(NotificationSnapshot {
        session_id: n.session_id,
        serial: n.serial,
        snapshot_uri: n.snapshot_uri,
        snapshot_hash_sha256: n.snapshot_hash_sha256,
    })
}

pub fn parse_delta_file(xml: &[u8]) -> Result<DeltaFile, RrdpError> {
    let doc = parse_rrdp_xml(xml)?;
    let root = doc.root_element();
    if root.tag_name().name() != "delta" {
        return Err(RrdpError::UnexpectedRoot(
            root.tag_name().name().to_string(),
        ));
    }
    validate_root_common(&root)?;

    let session_id = parse_uuid_attr(&root, "session_id")?;
    let serial = parse_u64_attr(&root, "serial")?;

    let mut elements: Vec<DeltaElement> = Vec::new();
    for child in root.children().filter(|n| n.is_element()) {
        match child.tag_name().name() {
            "publish" => {
                let uri = child
                    .attribute("uri")
                    .ok_or(RrdpError::DeltaPublishUriMissing)?
                    .to_string();
                let hash_sha256 = child
                    .attribute("hash")
                    .map(parse_sha256_hex_delta_publish)
                    .transpose()?;

                let content_b64 =
                    collect_element_text(&child).ok_or(RrdpError::DeltaPublishContentMissing)?;
                let content_b64 = strip_all_ascii_whitespace(&content_b64);
                if content_b64.is_empty() {
                    return Err(RrdpError::DeltaPublishContentMissing);
                }
                let bytes = base64::engine::general_purpose::STANDARD
                    .decode(content_b64.as_bytes())
                    .map_err(|e| RrdpError::DeltaPublishBase64(e.to_string()))?;

                elements.push(DeltaElement::Publish {
                    uri,
                    hash_sha256,
                    bytes,
                });
            }
            "withdraw" => {
                let uri = child
                    .attribute("uri")
                    .ok_or(RrdpError::DeltaWithdrawUriMissing)?
                    .to_string();
                let hash = child
                    .attribute("hash")
                    .ok_or(RrdpError::DeltaWithdrawHashMissing)?;
                let hash_sha256 = parse_sha256_hex_delta_withdraw(hash)?;

                if let Some(s) = collect_element_text(&child) {
                    if !strip_all_ascii_whitespace(&s).is_empty() {
                        return Err(RrdpError::DeltaWithdrawUnexpectedContent);
                    }
                }

                elements.push(DeltaElement::Withdraw { uri, hash_sha256 });
            }
            other => return Err(RrdpError::DeltaUnexpectedElement(other.to_string())),
        }
    }

    if elements.is_empty() {
        return Err(RrdpError::DeltaNoElements);
    }

    Ok(DeltaFile {
        session_id,
        serial,
        elements,
    })
}

pub fn sync_from_notification_snapshot(
    store: &RocksStore,
    notification_uri: &str,
    notification_xml: &[u8],
    fetcher: &dyn Fetcher,
) -> RrdpSyncResult<usize> {
    sync_from_notification_snapshot_inner(
        store,
        notification_uri,
        notification_xml,
        fetcher,
        None,
        None,
    )
}

pub fn sync_from_notification_snapshot_with_timing(
    store: &RocksStore,
    notification_uri: &str,
    notification_xml: &[u8],
    fetcher: &dyn Fetcher,
    timing: Option<&TimingHandle>,
) -> RrdpSyncResult<usize> {
    sync_from_notification_snapshot_inner(
        store,
        notification_uri,
        notification_xml,
        fetcher,
        timing,
        None,
    )
}

pub fn sync_from_notification_snapshot_with_timing_and_download_log(
    store: &RocksStore,
    notification_uri: &str,
    notification_xml: &[u8],
    fetcher: &dyn Fetcher,
    timing: Option<&TimingHandle>,
    download_log: Option<&DownloadLogHandle>,
) -> RrdpSyncResult<usize> {
    sync_from_notification_snapshot_inner(
        store,
        notification_uri,
        notification_xml,
        fetcher,
        timing,
        download_log,
    )
}

fn sync_from_notification_snapshot_inner(
    store: &RocksStore,
    notification_uri: &str,
    notification_xml: &[u8],
    fetcher: &dyn Fetcher,
    timing: Option<&TimingHandle>,
    download_log: Option<&DownloadLogHandle>,
) -> RrdpSyncResult<usize> {
    let _parse_step = timing
        .as_ref()
        .map(|t| t.span_rrdp_repo_step(notification_uri, "parse_notification_snapshot"));
    let _parse_total = timing
        .as_ref()
        .map(|t| t.span_phase("rrdp_parse_notification_total"));
    let notif = parse_notification_snapshot(notification_xml)?;
    drop(_parse_step);
    drop(_parse_total);

    let _fetch_step = timing
        .as_ref()
        .map(|t| t.span_rrdp_repo_step(notification_uri, "fetch_snapshot"));
    let _fetch_total = timing
        .as_ref()
        .map(|t| t.span_phase("rrdp_fetch_snapshot_total"));
    let mut dl_span = download_log
        .map(|dl| dl.span_download(AuditDownloadKind::RrdpSnapshot, &notif.snapshot_uri));
    let snapshot_xml = match fetcher.fetch(&notif.snapshot_uri) {
        Ok(v) => {
            if let Some(t) = timing.as_ref() {
                t.record_count("rrdp_snapshot_fetch_ok_total", 1);
                t.record_count("rrdp_snapshot_bytes_total", v.len() as u64);
            }
            if let Some(s) = dl_span.as_mut() {
                s.set_bytes(v.len() as u64);
                s.set_ok();
            }
            v
        }
        Err(e) => {
            if let Some(t) = timing.as_ref() {
                t.record_count("rrdp_snapshot_fetch_fail_total", 1);
            }
            if let Some(s) = dl_span.as_mut() {
                s.set_err(e.clone());
            }
            return Err(RrdpSyncError::Fetch(e));
        }
    };
    drop(_fetch_step);
    drop(_fetch_total);

    let _hash_step = timing
        .as_ref()
        .map(|t| t.span_rrdp_repo_step(notification_uri, "hash_snapshot"));
    let _hash_total = timing
        .as_ref()
        .map(|t| t.span_phase("rrdp_hash_snapshot_total"));
    let computed = sha2::Sha256::digest(&snapshot_xml);
    if computed.as_slice() != notif.snapshot_hash_sha256.as_slice() {
        return Err(RrdpError::SnapshotHashMismatch.into());
    }
    drop(_hash_step);
    drop(_hash_total);

    let _apply_step = timing
        .as_ref()
        .map(|t| t.span_rrdp_repo_step(notification_uri, "apply_snapshot"));
    let _apply_total = timing
        .as_ref()
        .map(|t| t.span_phase("rrdp_apply_snapshot_total"));
    let published = apply_snapshot(
        store,
        notification_uri,
        &snapshot_xml,
        notif.session_id,
        notif.serial,
    )?;
    if let Some(t) = timing.as_ref() {
        t.record_count("rrdp_snapshot_objects_applied_total", published as u64);
    }
    drop(_apply_step);
    drop(_apply_total);

    let _write_state_step = timing
        .as_ref()
        .map(|t| t.span_rrdp_repo_step(notification_uri, "write_state"));
    let _write_state_total = timing
        .as_ref()
        .map(|t| t.span_phase("rrdp_write_state_total"));
    let state = RrdpState {
        session_id: notif.session_id.to_string(),
        serial: notif.serial,
    };
    persist_rrdp_local_state(
        store,
        notification_uri,
        &state,
        RrdpSourceSyncState::SnapshotOnly,
        Some(&notif.snapshot_uri),
        Some(&hex::encode(notif.snapshot_hash_sha256)),
    )
    .map_err(RrdpSyncError::Storage)?;
    drop(_write_state_step);
    drop(_write_state_total);

    Ok(published)
}

pub fn sync_from_notification(
    store: &RocksStore,
    notification_uri: &str,
    notification_xml: &[u8],
    fetcher: &dyn Fetcher,
) -> RrdpSyncResult<usize> {
    sync_from_notification_inner(
        store,
        notification_uri,
        notification_xml,
        fetcher,
        None,
        None,
    )
}

pub fn sync_from_notification_with_timing(
    store: &RocksStore,
    notification_uri: &str,
    notification_xml: &[u8],
    fetcher: &dyn Fetcher,
    timing: Option<&TimingHandle>,
) -> RrdpSyncResult<usize> {
    sync_from_notification_inner(
        store,
        notification_uri,
        notification_xml,
        fetcher,
        timing,
        None,
    )
}

pub fn sync_from_notification_with_timing_and_download_log(
    store: &RocksStore,
    notification_uri: &str,
    notification_xml: &[u8],
    fetcher: &dyn Fetcher,
    timing: Option<&TimingHandle>,
    download_log: Option<&DownloadLogHandle>,
) -> RrdpSyncResult<usize> {
    sync_from_notification_inner(
        store,
        notification_uri,
        notification_xml,
        fetcher,
        timing,
        download_log,
    )
}

fn sync_from_notification_inner(
    store: &RocksStore,
    notification_uri: &str,
    notification_xml: &[u8],
    fetcher: &dyn Fetcher,
    timing: Option<&TimingHandle>,
    download_log: Option<&DownloadLogHandle>,
) -> RrdpSyncResult<usize> {
    let _parse_step = timing
        .as_ref()
        .map(|t| t.span_rrdp_repo_step(notification_uri, "parse_notification"));
    let _parse_total = timing
        .as_ref()
        .map(|t| t.span_phase("rrdp_parse_notification_total"));
    let notif = parse_notification(notification_xml)?;
    drop(_parse_step);
    drop(_parse_total);
    if let Some(t) = timing.as_ref() {
        t.record_count(
            "rrdp_notification_delta_refs_total",
            notif.deltas.len() as u64,
        );
    }

    let _read_state_step = timing
        .as_ref()
        .map(|t| t.span_rrdp_repo_step(notification_uri, "read_state"));
    let _read_state_total = timing
        .as_ref()
        .map(|t| t.span_phase("rrdp_read_state_total"));
    let state = load_rrdp_local_state(store, notification_uri).map_err(RrdpSyncError::Storage)?;
    drop(_read_state_step);
    drop(_read_state_total);

    let same_session_state = state
        .as_ref()
        .filter(|s| s.session_id == notif.session_id.to_string());

    if let Some(s) = same_session_state {
        if s.serial == notif.serial {
            return Ok(0);
        }
        if s.serial > notif.serial {
            return Err(RrdpError::NotificationSerialRollback {
                old: s.serial,
                new: notif.serial,
            }
            .into());
        }
    }

    if let Some(s) = same_session_state {
        // RFC 8182 §3.4.1: if session matches, MAY use deltas when a contiguous chain from the
        // last processed serial to the current serial can be processed (i.e. deltas cover the gap).
        let want_first = s.serial + 1;
        let want_last = notif.serial;

        if want_first <= want_last && !notif.deltas.is_empty() {
            let min_serial = notif.deltas[0].serial;
            let max_serial = notif.deltas[notif.deltas.len() - 1].serial;

            // `parse_notification` guarantees contiguity and max==notif.serial when deltas exist.
            if max_serial == notif.serial && want_first >= min_serial {
                // Fetch all required delta files first so a network failure doesn't leave us with
                // partially applied deltas and no snapshot fallback.
                let _fetch_d_step = timing
                    .as_ref()
                    .map(|t| t.span_rrdp_repo_step(notification_uri, "fetch_deltas"));
                let _fetch_d_total = timing
                    .as_ref()
                    .map(|t| t.span_phase("rrdp_fetch_deltas_total"));
                let mut fetched: Vec<(u64, [u8; 32], Vec<u8>)> =
                    Vec::with_capacity((want_last - want_first + 1) as usize);
                let mut fetch_ok = true;
                for serial in want_first..=want_last {
                    if let Some(t) = timing.as_ref() {
                        t.record_count("rrdp_delta_fetch_attempted_total", 1);
                    }
                    let idx = (serial - min_serial) as usize;
                    let dref = match notif.deltas.get(idx) {
                        Some(v) if v.serial == serial => v,
                        _ => {
                            fetch_ok = false;
                            break;
                        }
                    };

                    let mut dl_span = download_log
                        .map(|dl| dl.span_download(AuditDownloadKind::RrdpDelta, &dref.uri));
                    match fetcher.fetch(&dref.uri) {
                        Ok(bytes) => {
                            if let Some(t) = timing.as_ref() {
                                t.record_count("rrdp_delta_fetch_ok_total", 1);
                                t.record_count("rrdp_delta_bytes_total", bytes.len() as u64);
                            }
                            if let Some(s) = dl_span.as_mut() {
                                s.set_bytes(bytes.len() as u64);
                                s.set_ok();
                            }
                            fetched.push((serial, dref.hash_sha256, bytes))
                        }
                        Err(e) => {
                            if let Some(t) = timing.as_ref() {
                                t.record_count("rrdp_delta_fetch_fail_total", 1);
                            }
                            if let Some(s) = dl_span.as_mut() {
                                s.set_err(e.clone());
                            }
                            fetch_ok = false;
                            break;
                        }
                    }
                }
                drop(_fetch_d_step);
                drop(_fetch_d_total);

                if fetch_ok {
                    let _apply_d_step = timing
                        .as_ref()
                        .map(|t| t.span_rrdp_repo_step(notification_uri, "apply_deltas"));
                    let _apply_d_total = timing
                        .as_ref()
                        .map(|t| t.span_phase("rrdp_apply_deltas_total"));
                    let mut applied_total = 0usize;
                    let mut ok = true;
                    for (serial, expected_hash, bytes) in &fetched {
                        match apply_delta(
                            store,
                            notification_uri,
                            bytes.as_slice(),
                            *expected_hash,
                            notif.session_id,
                            *serial,
                        ) {
                            Ok(n) => applied_total += n,
                            Err(_) => {
                                ok = false;
                                break;
                            }
                        }
                    }
                    drop(_apply_d_step);
                    drop(_apply_d_total);

                    if ok {
                        let _write_state_step = timing
                            .as_ref()
                            .map(|t| t.span_rrdp_repo_step(notification_uri, "write_state"));
                        let _write_state_total = timing
                            .as_ref()
                            .map(|t| t.span_phase("rrdp_write_state_total"));
                        let new_state = RrdpState {
                            session_id: notif.session_id.to_string(),
                            serial: notif.serial,
                        };
                        persist_rrdp_local_state(
                            store,
                            notification_uri,
                            &new_state,
                            RrdpSourceSyncState::DeltaReady,
                            Some(&notif.snapshot_uri),
                            Some(&hex::encode(notif.snapshot_hash_sha256)),
                        )
                        .map_err(RrdpSyncError::Storage)?;
                        drop(_write_state_step);
                        drop(_write_state_total);
                        if let Some(t) = timing.as_ref() {
                            t.record_count("rrdp_delta_ops_applied_total", applied_total as u64);
                        }
                        return Ok(applied_total);
                    }
                }
            }
        }
    }

    // Snapshot fallback (RFC 8182 §3.4.3).
    let _fetch_step = timing
        .as_ref()
        .map(|t| t.span_rrdp_repo_step(notification_uri, "fetch_snapshot"));
    let _fetch_total = timing
        .as_ref()
        .map(|t| t.span_phase("rrdp_fetch_snapshot_total"));
    let mut dl_span = download_log
        .map(|dl| dl.span_download(AuditDownloadKind::RrdpSnapshot, &notif.snapshot_uri));
    let snapshot_xml = match fetcher.fetch(&notif.snapshot_uri) {
        Ok(v) => {
            if let Some(t) = timing.as_ref() {
                t.record_count("rrdp_snapshot_fetch_ok_total", 1);
                t.record_count("rrdp_snapshot_bytes_total", v.len() as u64);
            }
            if let Some(s) = dl_span.as_mut() {
                s.set_bytes(v.len() as u64);
                s.set_ok();
            }
            v
        }
        Err(e) => {
            if let Some(t) = timing.as_ref() {
                t.record_count("rrdp_snapshot_fetch_fail_total", 1);
            }
            if let Some(s) = dl_span.as_mut() {
                s.set_err(e.clone());
            }
            return Err(RrdpSyncError::Fetch(e));
        }
    };
    drop(_fetch_step);
    drop(_fetch_total);

    let _hash_step = timing
        .as_ref()
        .map(|t| t.span_rrdp_repo_step(notification_uri, "hash_snapshot"));
    let _hash_total = timing
        .as_ref()
        .map(|t| t.span_phase("rrdp_hash_snapshot_total"));
    let computed = sha2::Sha256::digest(&snapshot_xml);
    if computed.as_slice() != notif.snapshot_hash_sha256.as_slice() {
        return Err(RrdpError::SnapshotHashMismatch.into());
    }
    drop(_hash_step);
    drop(_hash_total);

    let _apply_step = timing
        .as_ref()
        .map(|t| t.span_rrdp_repo_step(notification_uri, "apply_snapshot"));
    let _apply_total = timing
        .as_ref()
        .map(|t| t.span_phase("rrdp_apply_snapshot_total"));
    let published = apply_snapshot(
        store,
        notification_uri,
        &snapshot_xml,
        notif.session_id,
        notif.serial,
    )?;
    if let Some(t) = timing.as_ref() {
        t.record_count("rrdp_snapshot_objects_applied_total", published as u64);
    }
    drop(_apply_step);
    drop(_apply_total);

    let _write_state_step = timing
        .as_ref()
        .map(|t| t.span_rrdp_repo_step(notification_uri, "write_state"));
    let _write_state_total = timing
        .as_ref()
        .map(|t| t.span_phase("rrdp_write_state_total"));
    let new_state = RrdpState {
        session_id: notif.session_id.to_string(),
        serial: notif.serial,
    };
    persist_rrdp_local_state(
        store,
        notification_uri,
        &new_state,
        RrdpSourceSyncState::SnapshotOnly,
        Some(&notif.snapshot_uri),
        Some(&hex::encode(notif.snapshot_hash_sha256)),
    )
    .map_err(RrdpSyncError::Storage)?;
    drop(_write_state_step);
    drop(_write_state_total);

    Ok(published)
}

fn apply_delta(
    store: &RocksStore,
    notification_uri: &str,
    delta_xml: &[u8],
    expected_hash_sha256: [u8; 32],
    expected_session_id: Uuid,
    expected_serial: u64,
) -> Result<usize, RrdpSyncError> {
    let computed = sha2::Sha256::digest(delta_xml);
    if computed.as_slice() != expected_hash_sha256.as_slice() {
        return Err(RrdpError::DeltaHashMismatch.into());
    }

    let delta = parse_delta_file(delta_xml)?;
    if delta.session_id != expected_session_id {
        return Err(RrdpError::DeltaSessionIdMismatch {
            expected: expected_session_id.to_string(),
            got: delta.session_id.to_string(),
        }
        .into());
    }
    if delta.serial != expected_serial {
        return Err(RrdpError::DeltaSerialMismatch {
            expected: expected_serial,
            got: delta.serial,
        }
        .into());
    }

    enum DeltaProjectionEffect {
        Upsert {
            rsync_uri: String,
            bytes: Vec<u8>,
        },
        Delete {
            rsync_uri: String,
            previous_hash: String,
        },
    }

    let session_id = expected_session_id.to_string();
    let mut ops: Vec<RrdpDeltaOp> = Vec::with_capacity(delta.elements.len());
    let mut projection: Vec<DeltaProjectionEffect> = Vec::with_capacity(delta.elements.len());
    for e in delta.elements {
        match e {
            DeltaElement::Publish {
                uri,
                hash_sha256: Some(old_hash),
                bytes,
            } => {
                let is_member = store
                    .is_current_rrdp_source_member(notification_uri, uri.as_str())
                    .map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
                if !is_member {
                    return Err(RrdpError::DeltaTargetNotFromRepository { rsync_uri: uri }.into());
                }
                ensure_rrdp_uri_can_be_owned_by(store, notification_uri, uri.as_str())
                    .map_err(RrdpSyncError::Storage)?;
                let old_bytes = store
                    .load_current_object_bytes_by_uri(uri.as_str())
                    .map_err(|e| RrdpSyncError::Storage(e.to_string()))?
                    .ok_or_else(|| RrdpError::DeltaTargetMissing {
                        rsync_uri: uri.clone(),
                    })?;
                let old_computed = sha2::Sha256::digest(old_bytes.as_slice());
                if old_computed.as_slice() != old_hash.as_slice() {
                    return Err(RrdpError::DeltaTargetHashMismatch { rsync_uri: uri }.into());
                }

                ops.push(RrdpDeltaOp::Upsert {
                    rsync_uri: uri.clone(),
                    bytes: bytes.clone(),
                });
                projection.push(DeltaProjectionEffect::Upsert {
                    rsync_uri: uri,
                    bytes,
                });
            }
            DeltaElement::Publish {
                uri,
                hash_sha256: None,
                bytes,
            } => {
                let is_member = store
                    .is_current_rrdp_source_member(notification_uri, uri.as_str())
                    .map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
                if is_member {
                    return Err(
                        RrdpError::DeltaPublishWithoutHashForExisting { rsync_uri: uri }.into(),
                    );
                }
                ensure_rrdp_uri_can_be_owned_by(store, notification_uri, uri.as_str())
                    .map_err(RrdpSyncError::Storage)?;
                ops.push(RrdpDeltaOp::Upsert {
                    rsync_uri: uri.clone(),
                    bytes: bytes.clone(),
                });
                projection.push(DeltaProjectionEffect::Upsert {
                    rsync_uri: uri,
                    bytes,
                });
            }
            DeltaElement::Withdraw { uri, hash_sha256 } => {
                let is_member = store
                    .is_current_rrdp_source_member(notification_uri, uri.as_str())
                    .map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
                if !is_member {
                    return Err(RrdpError::DeltaTargetNotFromRepository { rsync_uri: uri }.into());
                }
                ensure_rrdp_uri_can_be_owned_by(store, notification_uri, uri.as_str())
                    .map_err(RrdpSyncError::Storage)?;
                let old_bytes = store
                    .load_current_object_bytes_by_uri(uri.as_str())
                    .map_err(|e| RrdpSyncError::Storage(e.to_string()))?
                    .ok_or_else(|| RrdpError::DeltaTargetMissing {
                        rsync_uri: uri.clone(),
                    })?;
                let old_computed = sha2::Sha256::digest(old_bytes.as_slice());
                if old_computed.as_slice() != hash_sha256.as_slice() {
                    return Err(RrdpError::DeltaTargetHashMismatch { rsync_uri: uri }.into());
                }
                let previous_hash = hex::encode(old_computed);
                ops.push(RrdpDeltaOp::Delete {
                    rsync_uri: uri.clone(),
                });
                projection.push(DeltaProjectionEffect::Delete {
                    rsync_uri: uri,
                    previous_hash,
                });
            }
        }
    }

    for effect in projection {
        match effect {
            DeltaProjectionEffect::Upsert { rsync_uri, bytes } => {
                let current_hash = upsert_raw_by_hash_evidence(store, &rsync_uri, &bytes)
                    .map_err(RrdpSyncError::Storage)?;
                put_repository_view_present(store, notification_uri, &rsync_uri, &current_hash)
                    .map_err(RrdpSyncError::Storage)?;
                put_rrdp_source_member_present(
                    store,
                    notification_uri,
                    &session_id,
                    expected_serial,
                    &rsync_uri,
                    &current_hash,
                )
                .map_err(RrdpSyncError::Storage)?;
                put_rrdp_uri_owner_active(
                    store,
                    notification_uri,
                    &session_id,
                    expected_serial,
                    &rsync_uri,
                    &current_hash,
                )
                .map_err(RrdpSyncError::Storage)?;
            }
            DeltaProjectionEffect::Delete {
                rsync_uri,
                previous_hash,
            } => {
                put_rrdp_source_member_withdrawn(
                    store,
                    notification_uri,
                    &session_id,
                    expected_serial,
                    &rsync_uri,
                    Some(previous_hash.clone()),
                )
                .map_err(RrdpSyncError::Storage)?;
                if current_rrdp_owner_is(store, notification_uri, &rsync_uri)
                    .map_err(RrdpSyncError::Storage)?
                {
                    put_repository_view_withdrawn(
                        store,
                        notification_uri,
                        &rsync_uri,
                        Some(previous_hash.clone()),
                    )
                    .map_err(RrdpSyncError::Storage)?;
                    put_rrdp_uri_owner_withdrawn(
                        store,
                        notification_uri,
                        &session_id,
                        expected_serial,
                        &rsync_uri,
                        Some(previous_hash),
                    )
                    .map_err(RrdpSyncError::Storage)?;
                }
            }
        }
    }

    Ok(ops.len())
}

fn apply_snapshot(
    store: &RocksStore,
    notification_uri: &str,
    snapshot_xml: &[u8],
    expected_session_id: Uuid,
    expected_serial: u64,
) -> Result<usize, RrdpSyncError> {
    let doc = parse_rrdp_xml(snapshot_xml)?;
    let root = doc.root_element();
    if root.tag_name().name() != "snapshot" {
        return Err(RrdpError::UnexpectedRoot(root.tag_name().name().to_string()).into());
    }
    validate_root_common(&root)?;

    let got_session_id = parse_uuid_attr(&root, "session_id")?;
    if got_session_id != expected_session_id {
        return Err(RrdpError::SnapshotSessionIdMismatch {
            expected: expected_session_id.to_string(),
            got: got_session_id.to_string(),
        }
        .into());
    }
    let got_serial = parse_u64_attr(&root, "serial")?;
    if got_serial != expected_serial {
        return Err(RrdpError::SnapshotSerialMismatch {
            expected: expected_serial,
            got: got_serial,
        }
        .into());
    }

    let mut published: Vec<(String, Vec<u8>)> = Vec::new();
    for publish in root
        .children()
        .filter(|n| n.is_element() && n.tag_name().name() == "publish")
    {
        let uri = publish
            .attribute("uri")
            .ok_or(RrdpError::PublishUriMissing)?;
        let content_b64 = collect_element_text(&publish).ok_or(RrdpError::PublishContentMissing)?;
        let content_b64 = strip_all_ascii_whitespace(&content_b64);

        let bytes = base64::engine::general_purpose::STANDARD
            .decode(content_b64.as_bytes())
            .map_err(|e| RrdpError::PublishBase64(e.to_string()))?;

        ensure_rrdp_uri_can_be_owned_by(store, notification_uri, uri)
            .map_err(RrdpSyncError::Storage)?;
        published.push((uri.to_string(), bytes));
    }

    let previous_members: Vec<String> = store
        .list_current_rrdp_source_members(notification_uri)
        .map_err(|e| RrdpSyncError::Storage(e.to_string()))?
        .into_iter()
        .map(|record| record.rsync_uri)
        .collect();
    let new_set: std::collections::HashSet<&str> =
        published.iter().map(|(uri, _)| uri.as_str()).collect();
    let mut withdrawn: Vec<(String, Option<String>)> = Vec::new();
    for old_uri in &previous_members {
        if new_set.contains(old_uri.as_str()) {
            continue;
        }
        let previous_hash = store
            .get_repository_view_entry(old_uri)
            .map_err(|e| RrdpSyncError::Storage(e.to_string()))?
            .and_then(|entry| entry.current_hash)
            .or_else(|| {
                store
                    .load_current_object_bytes_by_uri(old_uri)
                    .ok()
                    .flatten()
                    .map(|bytes| compute_sha256_hex(&bytes))
            });
        withdrawn.push((old_uri.clone(), previous_hash));
    }

    let session_id = expected_session_id.to_string();
    let prepared_raw = prepare_raw_by_hash_evidence_batch(store, &published)
        .map_err(RrdpSyncError::Storage)?;
    let mut repository_view_entries = Vec::with_capacity(published.len() + withdrawn.len());
    let mut member_records = Vec::with_capacity(published.len() + withdrawn.len());
    let mut owner_records = Vec::with_capacity(published.len() + withdrawn.len());

    for (uri, _bytes) in &published {
        let current_hash = prepared_raw
            .uri_to_hash
            .get(uri)
            .cloned()
            .ok_or_else(|| {
                RrdpSyncError::Storage(format!("missing raw_by_hash mapping for {uri}"))
            })?;
        repository_view_entries.push(build_repository_view_present_entry(
            notification_uri,
            uri,
            &current_hash,
        ));
        member_records.push(build_rrdp_source_member_present_record(
            notification_uri,
            &session_id,
            expected_serial,
            uri,
            &current_hash,
        ));
        owner_records.push(build_rrdp_uri_owner_active_record(
            notification_uri,
            &session_id,
            expected_serial,
            uri,
            &current_hash,
        ));
    }

    for (uri, previous_hash) in withdrawn {
        member_records.push(build_rrdp_source_member_withdrawn_record(
            notification_uri,
            &session_id,
            expected_serial,
            &uri,
            previous_hash.clone(),
        ));
        if current_rrdp_owner_is(store, notification_uri, &uri).map_err(RrdpSyncError::Storage)? {
            repository_view_entries.push(build_repository_view_withdrawn_entry(
                notification_uri,
                &uri,
                previous_hash.clone(),
            ));
            owner_records.push(build_rrdp_uri_owner_withdrawn_record(
                notification_uri,
                &session_id,
                expected_serial,
                &uri,
                previous_hash,
            ));
        }
    }

    store
        .put_raw_by_hash_entries_batch_unchecked(&prepared_raw.entries_to_write)
        .map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
    store
        .put_projection_batch(&repository_view_entries, &member_records, &owner_records)
        .map_err(|e| RrdpSyncError::Storage(e.to_string()))?;

    Ok(published.len())
}

fn parse_rrdp_xml(xml: &[u8]) -> Result<roxmltree::Document<'_>, RrdpError> {
    if xml.iter().any(|&b| b > 0x7F) {
        return Err(RrdpError::NotAscii);
    }
    let s = std::str::from_utf8(xml).map_err(|e| RrdpError::Xml(e.to_string()))?;
    roxmltree::Document::parse(s).map_err(|e| RrdpError::Xml(e.to_string()))
}

fn validate_root_common(root: &roxmltree::Node<'_, '_>) -> Result<(), RrdpError> {
    let ns = root.default_namespace().unwrap_or("").to_string();
    if ns != RRDP_XMLNS {
        return Err(RrdpError::InvalidNamespace(ns));
    }

    let version = root.attribute("version").unwrap_or("");
    if version != "1" {
        return Err(RrdpError::InvalidVersion(version.to_string()));
    }

    Ok(())
}

fn parse_uuid_attr(root: &roxmltree::Node<'_, '_>, name: &'static str) -> Result<Uuid, RrdpError> {
    let s = root.attribute(name).unwrap_or("");
    Uuid::parse_str(s).map_err(|_e| RrdpError::InvalidSessionId(s.to_string()))
}

fn parse_u64_attr(root: &roxmltree::Node<'_, '_>, name: &'static str) -> Result<u64, RrdpError> {
    let s = root.attribute(name).unwrap_or("");
    parse_u64_str(s)
}

fn parse_u64_str(s: &str) -> Result<u64, RrdpError> {
    let v = s
        .parse::<u64>()
        .map_err(|_e| RrdpError::InvalidSerial(s.to_string()))?;
    if v == 0 {
        return Err(RrdpError::InvalidSerial(s.to_string()));
    }
    Ok(v)
}

fn parse_sha256_hex_impl(s: &str, invalid: fn(String) -> RrdpError) -> Result<[u8; 32], RrdpError> {
    let bytes = hex::decode(s).map_err(|_e| invalid(s.to_string()))?;
    if bytes.len() != 32 {
        return Err(invalid(s.to_string()));
    }
    let mut out = [0u8; 32];
    out.copy_from_slice(&bytes);
    Ok(out)
}

fn parse_sha256_hex_snapshot(s: &str) -> Result<[u8; 32], RrdpError> {
    parse_sha256_hex_impl(s, RrdpError::SnapshotHashInvalid)
}

fn parse_sha256_hex_delta_ref(s: &str) -> Result<[u8; 32], RrdpError> {
    parse_sha256_hex_impl(s, RrdpError::DeltaRefHashInvalid)
}

fn parse_sha256_hex_delta_publish(s: &str) -> Result<[u8; 32], RrdpError> {
    parse_sha256_hex_impl(s, RrdpError::DeltaPublishHashInvalid)
}

fn parse_sha256_hex_delta_withdraw(s: &str) -> Result<[u8; 32], RrdpError> {
    parse_sha256_hex_impl(s, RrdpError::DeltaWithdrawHashInvalid)
}

fn collect_element_text(node: &roxmltree::Node<'_, '_>) -> Option<String> {
    let mut out = String::new();
    for child in node.children() {
        if child.is_text() {
            out.push_str(child.text().unwrap_or(""));
        }
    }
    if out.is_empty() { None } else { Some(out) }
}

fn strip_all_ascii_whitespace(s: &str) -> String {
    s.chars().filter(|c| !c.is_ascii_whitespace()).collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::analysis::timing::{TimingHandle, TimingMeta};
    use crate::storage::RocksStore;
    use std::collections::HashMap;
    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)
        }
    }

    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 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,
            &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, &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, &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,
            &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,
            &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,
            &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, &delta, expected_hash, other_sid, 2).unwrap_err();
        assert!(matches!(
            err,
            RrdpSyncError::Rrdp(RrdpError::DeltaSessionIdMismatch { .. })
        ));

        // Serial mismatch.
        let err = apply_delta(
            &store,
            notif_uri,
            &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 raw = store
            .get_raw_by_hash_entry(hex::encode(sha2::Sha256::digest(b"mft-bytes")).as_str())
            .expect("get raw_by_hash")
            .expect("raw_by_hash exists");
        assert!(
            raw.origin_uris
                .iter()
                .any(|uri| uri == "rsync://example.net/repo/a.mft")
        );

        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 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, &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, &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, &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, &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, &xml, sid, 1).unwrap_err();
        assert!(matches!(
            err,
            RrdpSyncError::Rrdp(RrdpError::PublishBase64(_))
        ));
    }
}
