use crate::analysis::timing::TimingHandle;
use crate::audit::AuditDownloadKind;
use crate::audit_downloads::DownloadLogHandle;
use crate::current_repo_index::CurrentRepoIndexHandle;
use crate::storage::{
    RepositoryViewEntry, RepositoryViewState, RocksStore, RrdpDeltaOp, RrdpSourceSyncState,
};
mod snapshot_apply;

use crate::sync::store_projection::{
    build_repository_view_present_entry, build_repository_view_withdrawn_entry,
    current_rrdp_owner_is, ensure_rrdp_uri_can_be_owned_by, 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_repo_blob_bytes,
};
use base64::Engine;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use std::io::Write;
use uuid::Uuid;

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

#[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: Send + Sync {
    fn fetch(&self, uri: &str) -> Result<Vec<u8>, String>;

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

#[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_file, _snapshot_bytes) = match fetch_snapshot_into_tempfile(
        fetcher,
        &notif.snapshot_uri,
        &notif.snapshot_hash_sha256,
    ) {
        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.1);
            }
            if let Some(s) = dl_span.as_mut() {
                s.set_bytes(v.1);
                s.set_ok();
            }
            v
        }
        Err(RrdpSyncError::Fetch(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));
        }
        Err(e) => return Err(e),
    };
    drop(_fetch_step);
    drop(_fetch_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_from_bufread(
        store,
        notification_uri,
        None,
        std::io::BufReader::new(
            snapshot_file
                .reopen()
                .map_err(|e| RrdpSyncError::Fetch(format!("tempfile reopen failed: {e}")))?,
        ),
        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,
        None,
        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,
        None,
        notification_xml,
        fetcher,
        timing,
        None,
    )
}

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

fn sync_from_notification_inner(
    store: &RocksStore,
    notification_uri: &str,
    current_repo_index: Option<&CurrentRepoIndexHandle>,
    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 {
            let _hydrate_step = timing
                .as_ref()
                .map(|t| t.span_rrdp_repo_step(notification_uri, "hydrate_current_index"));
            let _hydrate_total = timing
                .as_ref()
                .map(|t| t.span_phase("rrdp_hydrate_current_index_total"));
            let hydrated = hydrate_current_repo_index_from_rrdp_members(
                store,
                notification_uri,
                current_repo_index,
            )?;
            drop(_hydrate_step);
            drop(_hydrate_total);
            if let Some(t) = timing.as_ref() {
                t.record_count("rrdp_current_index_hydrated_objects_total", hydrated as u64);
            }
            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,
                            current_repo_index,
                            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);
                        }
                        let _hydrate_step = timing.as_ref().map(|t| {
                            t.span_rrdp_repo_step(notification_uri, "hydrate_current_index")
                        });
                        let _hydrate_total = timing
                            .as_ref()
                            .map(|t| t.span_phase("rrdp_hydrate_current_index_total"));
                        let hydrated = hydrate_current_repo_index_from_rrdp_members(
                            store,
                            notification_uri,
                            current_repo_index,
                        )?;
                        drop(_hydrate_step);
                        drop(_hydrate_total);
                        if let Some(t) = timing.as_ref() {
                            t.record_count(
                                "rrdp_current_index_hydrated_objects_total",
                                hydrated 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_file, _snapshot_bytes) = match fetch_snapshot_into_tempfile(
        fetcher,
        &notif.snapshot_uri,
        &notif.snapshot_hash_sha256,
    ) {
        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.1);
            }
            if let Some(s) = dl_span.as_mut() {
                s.set_bytes(v.1);
                s.set_ok();
            }
            v
        }
        Err(RrdpSyncError::Fetch(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));
        }
        Err(e) => return Err(e),
    };
    drop(_fetch_step);
    drop(_fetch_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_from_bufread(
        store,
        notification_uri,
        current_repo_index,
        std::io::BufReader::new(
            snapshot_file
                .reopen()
                .map_err(|e| RrdpSyncError::Fetch(format!("tempfile reopen failed: {e}")))?,
        ),
        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 hydrate_current_repo_index_from_rrdp_members(
    store: &RocksStore,
    notification_uri: &str,
    current_repo_index: Option<&CurrentRepoIndexHandle>,
) -> Result<usize, RrdpSyncError> {
    let Some(index) = current_repo_index else {
        return Ok(0);
    };

    let members = store
        .list_current_rrdp_source_members(notification_uri)
        .map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
    if members.is_empty() {
        return Ok(0);
    }

    let mut entries = Vec::with_capacity(members.len());
    for member in members {
        let current_hash = member.current_hash.ok_or_else(|| {
            RrdpSyncError::Storage(format!(
                "rrdp source member missing current_hash for current object {}",
                member.rsync_uri
            ))
        })?;
        entries.push(RepositoryViewEntry {
            rsync_uri: member.rsync_uri,
            current_hash: Some(current_hash),
            repository_source: Some(notification_uri.to_string()),
            object_type: member.object_type,
            state: RepositoryViewState::Present,
        });
    }

    index
        .write()
        .map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
        .apply_repository_view_entries(&entries)
        .map_err(RrdpSyncError::Storage)?;
    Ok(entries.len())
}

fn apply_delta(
    store: &RocksStore,
    notification_uri: &str,
    current_repo_index: Option<&CurrentRepoIndexHandle>,
    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_repo_blob_bytes(store, &bytes).map_err(RrdpSyncError::Storage)?;
                put_repository_view_present(store, notification_uri, &rsync_uri, &current_hash)
                    .map_err(RrdpSyncError::Storage)?;
                if let Some(index) = current_repo_index {
                    let entry = build_repository_view_present_entry(
                        notification_uri,
                        &rsync_uri,
                        &current_hash,
                    );
                    index
                        .write()
                        .map_err(|_| {
                            RrdpSyncError::Storage("current repo index lock poisoned".to_string())
                        })?
                        .apply_repository_view_entries(&[entry])
                        .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)?;
                    if let Some(index) = current_repo_index {
                        let entry = build_repository_view_withdrawn_entry(
                            notification_uri,
                            &rsync_uri,
                            Some(previous_hash.clone()),
                        );
                        index
                            .write()
                            .map_err(|_| {
                                RrdpSyncError::Storage(
                                    "current repo index lock poisoned".to_string(),
                                )
                            })?
                            .apply_repository_view_entries(&[entry])
                            .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())
}

#[cfg(test)]
use snapshot_apply::apply_snapshot;
use snapshot_apply::{apply_snapshot_from_bufread, fetch_snapshot_into_tempfile};

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)]
#[path = "rrdp/tests.rs"]
mod tests;
