use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use serde::Serialize;
use time::format_description::well_known::Rfc3339;

use crate::audit::PublicationPointAudit;
use crate::fetch::rsync::{RsyncFetchError, RsyncFetcher};
use crate::replay::archive::{ReplayArchiveIndex, ReplayRrdpLock, ReplayTransport, canonical_rsync_module, sha256_hex};
use crate::sync::rrdp::{NotificationDeltaRef, parse_notification};
use crate::storage::{RocksStore, RrdpSourceRecord};
use crate::sync::rrdp::Fetcher;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecordedHttpResponse {
    pub uri: String,
    pub bytes: Vec<u8>,
    pub fetched_at_rfc3339_utc: String,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecordedRsyncFetch {
    pub requested_base_uri: String,
    pub objects: Vec<(String, Vec<u8>)>,
    pub fetched_at_rfc3339_utc: String,
}

#[derive(Clone, Default)]
pub struct RecordingHttpFetcher<F> {
    inner: F,
    responses: Arc<Mutex<BTreeMap<String, RecordedHttpResponse>>>,
}

impl<F> RecordingHttpFetcher<F> {
    pub fn new(inner: F) -> Self {
        Self {
            inner,
            responses: Arc::new(Mutex::new(BTreeMap::new())),
        }
    }

    pub fn snapshot_responses(&self) -> BTreeMap<String, RecordedHttpResponse> {
        self.responses.lock().expect("http recorder lock").clone()
    }
}

impl<F: Fetcher> Fetcher for RecordingHttpFetcher<F> {
    fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
        let bytes = self.inner.fetch(uri)?;
        let fetched_at_rfc3339_utc = time::OffsetDateTime::now_utc()
            .format(&Rfc3339)
            .unwrap_or_else(|_| "<format-error>".to_string());
        self.responses.lock().expect("http recorder lock").insert(
            uri.to_string(),
            RecordedHttpResponse {
                uri: uri.to_string(),
                bytes: bytes.clone(),
                fetched_at_rfc3339_utc,
            },
        );
        Ok(bytes)
    }
}

#[derive(Clone, Default)]
pub struct RecordingRsyncFetcher<F> {
    inner: F,
    fetches: Arc<Mutex<BTreeMap<String, RecordedRsyncFetch>>>,
    capture_objects: bool,
}

impl<F> RecordingRsyncFetcher<F> {
    pub fn new(inner: F) -> Self {
        Self {
            inner,
            fetches: Arc::new(Mutex::new(BTreeMap::new())),
            capture_objects: true,
        }
    }

    pub fn new_without_objects(inner: F) -> Self {
        Self {
            inner,
            fetches: Arc::new(Mutex::new(BTreeMap::new())),
            capture_objects: false,
        }
    }

    pub fn snapshot_fetches(&self) -> BTreeMap<String, RecordedRsyncFetch> {
        self.fetches.lock().expect("rsync recorder lock").clone()
    }
}

impl<F: RsyncFetcher> RsyncFetcher for RecordingRsyncFetcher<F> {
    fn fetch_objects(
        &self,
        rsync_base_uri: &str,
    ) -> Result<Vec<(String, Vec<u8>)>, RsyncFetchError> {
        let objects = self.inner.fetch_objects(rsync_base_uri)?;
        let fetched_at_rfc3339_utc = time::OffsetDateTime::now_utc()
            .format(&Rfc3339)
            .unwrap_or_else(|_| "<format-error>".to_string());
        self.fetches.lock().expect("rsync recorder lock").insert(
            rsync_base_uri.to_string(),
            RecordedRsyncFetch {
                requested_base_uri: rsync_base_uri.to_string(),
                objects: objects.clone(),
                fetched_at_rfc3339_utc,
            },
        );
        Ok(objects)
    }

    fn visit_objects(
        &self,
        rsync_base_uri: &str,
        visitor: &mut dyn FnMut(String, Vec<u8>) -> Result<(), String>,
    ) -> Result<(usize, u64), RsyncFetchError> {
        let fetched_at_rfc3339_utc = time::OffsetDateTime::now_utc()
            .format(&Rfc3339)
            .unwrap_or_else(|_| "<format-error>".to_string());

        if self.capture_objects {
            let mut recorded = Vec::new();
            let result = self.inner.visit_objects(rsync_base_uri, &mut |uri, bytes| {
                recorded.push((uri.clone(), bytes.clone()));
                visitor(uri, bytes)
            })?;
            self.fetches.lock().expect("rsync recorder lock").insert(
                rsync_base_uri.to_string(),
                RecordedRsyncFetch {
                    requested_base_uri: rsync_base_uri.to_string(),
                    objects: recorded,
                    fetched_at_rfc3339_utc,
                },
            );
            Ok(result)
        } else {
            self.inner.visit_objects(rsync_base_uri, visitor)
        }
    }

    fn dedup_key(&self, rsync_base_uri: &str) -> String {
        self.inner.dedup_key(rsync_base_uri)
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LiveBaseCaptureSummary {
    pub archive_root: PathBuf,
    pub locks_path: PathBuf,
    pub capture_id: String,
    pub rrdp_repo_count: usize,
    pub rsync_module_count: usize,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct CaptureMetaJson {
    version: u32,
    #[serde(rename = "captureId")]
    capture_id: String,
    #[serde(rename = "createdAt")]
    created_at: String,
    notes: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct RepoMetaJson {
    version: u32,
    #[serde(rename = "rpkiNotify")]
    rpki_notify: String,
    #[serde(rename = "createdAt")]
    created_at: String,
    #[serde(rename = "lastSeenAt")]
    last_seen_at: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct ModuleMetaJson {
    version: u32,
    module: String,
    #[serde(rename = "createdAt")]
    created_at: String,
    #[serde(rename = "lastSeenAt")]
    last_seen_at: String,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
enum TransportJson {
    Rrdp,
    Rsync,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct RrdpLockJson {
    transport: TransportJson,
    session: Option<String>,
    serial: Option<u64>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct RsyncLockJson {
    transport: TransportJson,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct ReplayLocksJson {
    version: u32,
    capture: String,
    #[serde(rename = "validationTime")]
    validation_time: String,
    rrdp: BTreeMap<String, RrdpLockJson>,
    rsync: BTreeMap<String, RsyncLockJson>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
enum DeltaKindJson {
    Unchanged,
    Delta,
    FallbackRsync,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct DeltaBaseMetaJson {
    version: u32,
    #[serde(rename = "baseCapture")]
    base_capture: String,
    #[serde(rename = "baseLocksSha256")]
    base_locks_sha256: String,
    #[serde(rename = "createdAt")]
    created_at: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct DeltaStateJson {
    transport: TransportJson,
    session: Option<String>,
    serial: Option<u64>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct DeltaRrdpEntryJson {
    kind: DeltaKindJson,
    base: DeltaStateJson,
    target: DeltaStateJson,
    #[serde(rename = "delta_count")]
    delta_count: usize,
    deltas: Vec<u64>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct DeltaRsyncEntryJson {
    #[serde(rename = "file_count")]
    file_count: usize,
    #[serde(rename = "overlay_only")]
    overlay_only: bool,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct DeltaTransitionJson {
    kind: DeltaKindJson,
    base: DeltaStateJson,
    target: DeltaStateJson,
    #[serde(rename = "delta_count")]
    delta_count: usize,
    deltas: Vec<u64>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct ReplayDeltaLocksJson {
    version: u32,
    capture: String,
    #[serde(rename = "baseCapture")]
    base_capture: String,
    #[serde(rename = "baseLocksSha256")]
    base_locks_sha256: String,
    #[serde(rename = "validationTime")]
    validation_time: String,
    rrdp: BTreeMap<String, DeltaRrdpEntryJson>,
    rsync: BTreeMap<String, DeltaRsyncEntryJson>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct ReplayDeltaRsyncFilesJson {
    version: u32,
    module: String,
    #[serde(rename = "fileCount")]
    file_count: usize,
    files: Vec<String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LiveDeltaCaptureSummary {
    pub archive_root: PathBuf,
    pub locks_path: PathBuf,
    pub capture_id: String,
    pub rrdp_repo_count: usize,
    pub rsync_module_count: usize,
}

fn write_json(path: &Path, value: &impl Serialize) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("create parent failed: {}: {e}", parent.display()))?;
    }
    let bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
    fs::write(path, bytes).map_err(|e| format!("write json failed: {}: {e}", path.display()))
}

fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("create parent failed: {}: {e}", parent.display()))?;
    }
    fs::write(path, bytes).map_err(|e| format!("write file failed: {}: {e}", path.display()))
}

fn base_capture_id(rir: &str, recorded_at_utc: &time::OffsetDateTime) -> String {
    let ts = recorded_at_utc
        .format(&Rfc3339)
        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
        .replace(':', "")
        .replace('-', "");
    format!("{rir}-base-{}", ts.replace('+', "_"))
}

fn rrdp_repo_lock_and_record(
    store: &RocksStore,
    notify_uri: &str,
) -> Result<Option<RrdpSourceRecord>, String> {
    store
        .get_rrdp_source_record(notify_uri)
        .map_err(|e| format!("read rrdp source record failed for {notify_uri}: {e}"))
}

fn rrdp_repo_is_replayable(record: &RrdpSourceRecord) -> bool {
    record.last_session_id.is_some()
        && record.last_serial.is_some()
        && record.last_snapshot_uri.is_some()
        && record.last_snapshot_hash.is_some()
}

fn collect_current_state_locks(
    publication_points: &[PublicationPointAudit],
    store: &RocksStore,
) -> Result<(BTreeMap<String, RrdpLockJson>, BTreeMap<String, RsyncLockJson>), String> {
    let mut rrdp_locks = BTreeMap::new();
    let mut rsync_locks = BTreeMap::new();
    let mut seen_modules = BTreeSet::new();
    let mut seen_rrdp = BTreeSet::new();

    for pp in publication_points {
        if pp.source == "failed_fetch_no_cache" {
            continue;
        }
        let module_uri = canonical_rsync_module(&pp.rsync_base_uri)
            .map_err(|e| format!("canonicalize rsync module failed for {}: {e}", pp.rsync_base_uri))?;
        if let Some(notify_uri) = pp.rrdp_notification_uri.as_deref() {
            if !seen_rrdp.insert(notify_uri.to_string()) {
                continue;
            }
            if let Some(source_record) = rrdp_repo_lock_and_record(store, notify_uri)? {
                if rrdp_repo_is_replayable(&source_record) {
                    rrdp_locks.insert(
                        notify_uri.to_string(),
                        RrdpLockJson {
                            transport: TransportJson::Rrdp,
                            session: source_record.last_session_id.clone(),
                            serial: source_record.last_serial,
                        },
                    );
                    continue;
                }
            }
            rrdp_locks.insert(
                notify_uri.to_string(),
                RrdpLockJson {
                    transport: TransportJson::Rsync,
                    session: None,
                    serial: None,
                },
            );
            if seen_modules.insert(module_uri.clone()) {
                rsync_locks.insert(
                    module_uri.clone(),
                    RsyncLockJson {
                        transport: TransportJson::Rsync,
                    },
                );
            }
        } else if seen_modules.insert(module_uri.clone()) {
            rsync_locks.insert(
                module_uri.clone(),
                RsyncLockJson {
                    transport: TransportJson::Rsync,
                },
            );
        }
    }

    Ok((rrdp_locks, rsync_locks))
}

pub fn write_current_replay_state_locks(
    output_path: &Path,
    capture_id: &str,
    validation_time: time::OffsetDateTime,
    publication_points: &[PublicationPointAudit],
    store: &RocksStore,
) -> Result<(), String> {
    let (rrdp_locks, rsync_locks) = collect_current_state_locks(publication_points, store)?;
    let locks = ReplayLocksJson {
        version: 1,
        capture: capture_id.to_string(),
        validation_time: validation_time
            .format(&Rfc3339)
            .map_err(|e| format!("format validation time failed: {e}"))?,
        rrdp: rrdp_locks,
        rsync: rsync_locks,
    };
    write_json(output_path, &locks)
}

fn materialize_rrdp_repo(
    capture_root: &Path,
    record: &RrdpSourceRecord,
    notification_bytes: &[u8],
    snapshot_bytes: &[u8],
) -> Result<(), String> {
    let notify_uri = &record.notify_uri;
    let session = record
        .last_session_id
        .as_deref()
        .ok_or_else(|| format!("missing last_session_id for {notify_uri}"))?;
    let serial = record
        .last_serial
        .ok_or_else(|| format!("missing last_serial for {notify_uri}"))?;
    let snapshot_hash = record
        .last_snapshot_hash
        .as_deref()
        .ok_or_else(|| format!("missing last_snapshot_hash for {notify_uri}"))?;
    let bucket_hash = sha256_hex(notify_uri.as_bytes());
    let bucket_dir = capture_root.join("rrdp").join("repos").join(&bucket_hash);
    let session_dir = bucket_dir.join(session);
    let created_at = record.first_seen_at.clone();
    let last_seen_at = record.last_seen_at.clone();
    write_json(
        &bucket_dir.join("meta.json"),
        &RepoMetaJson {
            version: 1,
            rpki_notify: notify_uri.clone(),
            created_at: created_at.rfc3339_utc.clone(),
            last_seen_at: last_seen_at.rfc3339_utc.clone(),
        },
    )?;
    write_bytes(
        &session_dir.join(format!("notification-{serial}.xml")),
        notification_bytes,
    )?;
    write_bytes(
        &session_dir.join(format!("snapshot-{serial}-{snapshot_hash}.xml")),
        snapshot_bytes,
    )?;
    Ok(())
}

fn materialize_rsync_module(
    capture_root: &Path,
    module_uri: &str,
    objects: &BTreeMap<String, Vec<u8>>,
    created_at: &str,
    last_seen_at: &str,
) -> Result<(), String> {
    let bucket_hash = sha256_hex(module_uri.as_bytes());
    let bucket_dir = capture_root.join("rsync").join("modules").join(&bucket_hash);
    write_json(
        &bucket_dir.join("meta.json"),
        &ModuleMetaJson {
            version: 1,
            module: module_uri.to_string(),
            created_at: created_at.to_string(),
            last_seen_at: last_seen_at.to_string(),
        },
    )?;
    let without_scheme = module_uri
        .strip_prefix("rsync://")
        .ok_or_else(|| format!("invalid rsync module uri: {module_uri}"))?;
    let relative_root = without_scheme.trim_end_matches('/');
    fs::create_dir_all(bucket_dir.join("tree").join(relative_root))
        .map_err(|e| format!("create rsync tree root failed: {}: {e}", bucket_dir.join("tree").join(relative_root).display()))?;
    for (uri, bytes) in objects {
        let rel = uri
            .strip_prefix(module_uri)
            .ok_or_else(|| format!("object uri {uri} does not belong to module {module_uri}"))?;
        let path = bucket_dir.join("tree").join(relative_root).join(rel);
        write_bytes(&path, bytes)?;
    }
    Ok(())
}

fn current_module_objects_from_store(
    store: &RocksStore,
    module_uri: &str,
) -> Result<BTreeMap<String, Vec<u8>>, String> {
    let entries = store
        .list_repository_view_entries_with_prefix(module_uri)
        .map_err(|e| format!("list repository view failed for {module_uri}: {e}"))?;
    let mut out = BTreeMap::new();
    for entry in entries {
        if entry.state != crate::storage::RepositoryViewState::Present {
            continue;
        }
        let bytes = store
            .load_current_object_bytes_by_uri(&entry.rsync_uri)
            .map_err(|e| format!("load current object failed for {}: {e}", entry.rsync_uri))?
            .ok_or_else(|| format!("current object missing for {}", entry.rsync_uri))?;
        out.insert(entry.rsync_uri, bytes);
    }
    Ok(out)
}

pub fn write_live_base_replay_bundle_inputs(
    rir_dir: &Path,
    rir: &str,
    validation_time: time::OffsetDateTime,
    publication_points: &[PublicationPointAudit],
    store: &RocksStore,
    http_records: &BTreeMap<String, RecordedHttpResponse>,
    rsync_records: &BTreeMap<String, RecordedRsyncFetch>,
) -> Result<LiveBaseCaptureSummary, String> {
    let recorded_at = time::OffsetDateTime::now_utc();
    let capture_id = base_capture_id(rir, &recorded_at);
    let archive_root = rir_dir.join("base-payload-archive");
    let capture_root = archive_root.join("v1").join("captures").join(&capture_id);
    write_json(
        &capture_root.join("capture.json"),
        &CaptureMetaJson {
            version: 1,
            capture_id: capture_id.clone(),
            created_at: recorded_at
                .format(&Rfc3339)
                .map_err(|e| format!("format createdAt failed: {e}"))?,
            notes: format!("recorded by ours live base recorder for {rir}"),
        },
    )?;

    let mut rrdp_locks = BTreeMap::new();
    let mut rsync_locks = BTreeMap::new();
    let mut seen_modules = BTreeSet::new();
    let mut seen_rrdp = BTreeSet::new();

    for pp in publication_points {
        if pp.source == "failed_fetch_no_cache" {
            continue;
        }
        let module_uri = canonical_rsync_module(&pp.rsync_base_uri)
            .map_err(|e| format!("canonicalize rsync module failed for {}: {e}", pp.rsync_base_uri))?;
        if let Some(notify_uri) = pp.rrdp_notification_uri.as_deref() {
            if !seen_rrdp.insert(notify_uri.to_string()) {
                continue;
            }
            if let Some(source_record) = rrdp_repo_lock_and_record(store, notify_uri)? {
                if rrdp_repo_is_replayable(&source_record) {
                    let notification_bytes = &http_records
                        .get(notify_uri)
                        .ok_or_else(|| format!("missing recorded notification body for {notify_uri}"))?
                        .bytes;
                    let snapshot_uri = source_record
                        .last_snapshot_uri
                        .as_deref()
                        .ok_or_else(|| format!("missing last_snapshot_uri for {notify_uri}"))?;
                    let snapshot_bytes = &http_records
                        .get(snapshot_uri)
                        .ok_or_else(|| format!("missing recorded snapshot body for {snapshot_uri}"))?
                        .bytes;
                    materialize_rrdp_repo(&capture_root, &source_record, notification_bytes, snapshot_bytes)?;
                    rrdp_locks.insert(
                        notify_uri.to_string(),
                        RrdpLockJson {
                            transport: TransportJson::Rrdp,
                            session: source_record.last_session_id.clone(),
                            serial: source_record.last_serial,
                        },
                    );
                    continue;
                }
            }

            rrdp_locks.insert(
                notify_uri.to_string(),
                RrdpLockJson {
                    transport: TransportJson::Rsync,
                    session: None,
                    serial: None,
                },
            );
            if seen_modules.insert(module_uri.clone()) {
                rsync_locks.insert(
                    module_uri.clone(),
                    RsyncLockJson {
                        transport: TransportJson::Rsync,
                    },
                );
            }
        } else if seen_modules.insert(module_uri.clone()) {
            rsync_locks.insert(
                module_uri.clone(),
                RsyncLockJson {
                    transport: TransportJson::Rsync,
                },
            );
        }
    }

    let mut rsync_objects_by_module: BTreeMap<String, BTreeMap<String, Vec<u8>>> = BTreeMap::new();
    let mut rsync_times_by_module: BTreeMap<String, (String, String)> = BTreeMap::new();
    for fetch in rsync_records.values() {
        let module_uri = canonical_rsync_module(&fetch.requested_base_uri)
            .map_err(|e| format!("canonicalize requested rsync module failed: {e}"))?;
        let objects = rsync_objects_by_module.entry(module_uri.clone()).or_default();
        let times = rsync_times_by_module
            .entry(module_uri)
            .or_insert_with(|| (fetch.fetched_at_rfc3339_utc.clone(), fetch.fetched_at_rfc3339_utc.clone()));
        if fetch.fetched_at_rfc3339_utc < times.0 {
            times.0 = fetch.fetched_at_rfc3339_utc.clone();
        }
        if fetch.fetched_at_rfc3339_utc > times.1 {
            times.1 = fetch.fetched_at_rfc3339_utc.clone();
        }
        for (uri, bytes) in &fetch.objects {
            objects.insert(uri.clone(), bytes.clone());
        }
    }

    for module_uri in rsync_locks.keys() {
        let owned_objects;
        let objects = if let Some(objects) = rsync_objects_by_module.get(module_uri) {
            objects
        } else {
            owned_objects = current_module_objects_from_store(store, module_uri)?;
            &owned_objects
        };
        let (created_at, last_seen_at) = rsync_times_by_module
            .get(module_uri)
            .cloned()
            .unwrap_or_else(|| {
                let now = recorded_at
                    .format(&Rfc3339)
                    .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
                (now.clone(), now)
            });
        materialize_rsync_module(&capture_root, module_uri, objects, &created_at, &last_seen_at)?;
    }

    let locks = ReplayLocksJson {
        version: 1,
        capture: capture_id.clone(),
        validation_time: validation_time
            .format(&Rfc3339)
            .map_err(|e| format!("format validation time failed: {e}"))?,
        rrdp: rrdp_locks,
        rsync: rsync_locks,
    };
    let locks_path = rir_dir.join("base-locks.json");
    write_json(&locks_path, &locks)?;

    ReplayArchiveIndex::load_allow_missing_rsync_modules(&archive_root, &locks_path)
        .map_err(|e| format!("replay archive self-validate failed: {e}"))?;

    Ok(LiveBaseCaptureSummary {
        archive_root,
        locks_path,
        capture_id,
        rrdp_repo_count: locks.rrdp.len(),
        rsync_module_count: locks.rsync.len(),
    })
}

fn target_rrdp_state_from_record(record: &RrdpSourceRecord) -> Option<DeltaStateJson> {
    Some(DeltaStateJson {
        transport: TransportJson::Rrdp,
        session: Some(record.last_session_id.clone()?),
        serial: record.last_serial,
    })
}

fn fallback_rsync_state() -> DeltaStateJson {
    DeltaStateJson {
        transport: TransportJson::Rsync,
        session: None,
        serial: None,
    }
}

fn delta_state_from_base_lock(lock: Option<&ReplayRrdpLock>) -> DeltaStateJson {
    match lock {
        Some(lock) if lock.transport == ReplayTransport::Rrdp => DeltaStateJson {
            transport: TransportJson::Rrdp,
            session: lock.session.clone(),
            serial: lock.serial,
        },
        _ => fallback_rsync_state(),
    }
}

fn write_delta_repo_meta(
    bucket_dir: &Path,
    notify_uri: &str,
    created_at: &str,
    last_seen_at: &str,
) -> Result<(), String> {
    write_json(
        &bucket_dir.join("meta.json"),
        &RepoMetaJson {
            version: 1,
            rpki_notify: notify_uri.to_string(),
            created_at: created_at.to_string(),
            last_seen_at: last_seen_at.to_string(),
        },
    )
}

fn write_delta_transition(
    bucket_dir: &Path,
    transition: &DeltaTransitionJson,
) -> Result<(), String> {
    write_json(&bucket_dir.join("transition.json"), transition)
}

fn notification_deltas_after_serial(
    notification_bytes: &[u8],
    base_serial: u64,
    target_serial: u64,
) -> Result<Vec<NotificationDeltaRef>, String> {
    let notification = parse_notification(notification_bytes)
        .map_err(|e| format!("parse notification failed: {e}"))?;
    if notification.serial != target_serial {
        return Err(format!(
            "target notification serial mismatch: expected {target_serial}, actual {}",
            notification.serial
        ));
    }
    Ok(notification
        .deltas
        .into_iter()
        .filter(|d| d.serial > base_serial && d.serial <= target_serial)
        .collect())
}

pub fn write_live_delta_replay_step_inputs(
    step_dir: &Path,
    rir: &str,
    previous_locks_path: &Path,
    target_validation_time: time::OffsetDateTime,
    publication_points: &[PublicationPointAudit],
    store: &RocksStore,
    http_records: &BTreeMap<String, RecordedHttpResponse>,
    rsync_records: &BTreeMap<String, RecordedRsyncFetch>,
) -> Result<LiveDeltaCaptureSummary, String> {
    let previous_locks: crate::replay::archive::ReplayLocks = serde_json::from_slice(
        &fs::read(previous_locks_path)
            .map_err(|e| format!("read previous locks failed: {}: {e}", previous_locks_path.display()))?,
    )
    .map_err(|e| format!("parse previous locks failed: {}: {e}", previous_locks_path.display()))?;
    let previous_locks_bytes = fs::read(previous_locks_path)
        .map_err(|e| format!("read previous locks bytes failed: {}: {e}", previous_locks_path.display()))?;
    let previous_locks_sha256 = sha256_hex(&previous_locks_bytes);

    let recorded_at = time::OffsetDateTime::now_utc();
    let capture_id = format!(
        "{rir}-delta-{}",
        recorded_at
            .format(&Rfc3339)
            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
            .replace(':', "")
            .replace('-', "")
            .replace('+', "_")
    );
    let archive_root = step_dir.join("payload-delta-archive");
    let capture_root = archive_root.join("v1").join("captures").join(&capture_id);
    write_json(
        &capture_root.join("capture.json"),
        &CaptureMetaJson {
            version: 1,
            capture_id: capture_id.clone(),
            created_at: recorded_at
                .format(&Rfc3339)
                .map_err(|e| format!("format createdAt failed: {e}"))?,
            notes: format!("recorded by ours live delta recorder for {rir}"),
        },
    )?;
    write_json(
        &capture_root.join("base.json"),
        &DeltaBaseMetaJson {
            version: 1,
            base_capture: previous_locks.capture.clone(),
            base_locks_sha256: previous_locks_sha256.clone(),
            created_at: recorded_at
                .format(&Rfc3339)
                .map_err(|e| format!("format createdAt failed: {e}"))?,
        },
    )?;

    let mut rsync_objects_by_module: BTreeMap<String, BTreeMap<String, Vec<u8>>> = BTreeMap::new();
    let mut rsync_times_by_module: BTreeMap<String, (String, String)> = BTreeMap::new();
    for fetch in rsync_records.values() {
        let module_uri = canonical_rsync_module(&fetch.requested_base_uri)
            .map_err(|e| format!("canonicalize requested rsync module failed: {e}"))?;
        let objects = rsync_objects_by_module.entry(module_uri.clone()).or_default();
        let times = rsync_times_by_module
            .entry(module_uri)
            .or_insert_with(|| (fetch.fetched_at_rfc3339_utc.clone(), fetch.fetched_at_rfc3339_utc.clone()));
        if fetch.fetched_at_rfc3339_utc < times.0 {
            times.0 = fetch.fetched_at_rfc3339_utc.clone();
        }
        if fetch.fetched_at_rfc3339_utc > times.1 {
            times.1 = fetch.fetched_at_rfc3339_utc.clone();
        }
        for (uri, bytes) in &fetch.objects {
            objects.insert(uri.clone(), bytes.clone());
        }
    }

    let mut delta_rrdp_locks = BTreeMap::new();
    let mut delta_rsync_locks = BTreeMap::new();
    let mut seen_notifications = BTreeSet::new();
    let mut needed_modules = BTreeSet::new();

    for pp in publication_points {
        if pp.source == "failed_fetch_no_cache" {
            continue;
        }
        let module_uri = canonical_rsync_module(&pp.rsync_base_uri)
            .map_err(|e| format!("canonicalize rsync module failed for {}: {e}", pp.rsync_base_uri))?;
        if let Some(notify_uri) = pp.rrdp_notification_uri.as_deref() {
            if !seen_notifications.insert(notify_uri.to_string()) {
                continue;
            }
            let base_lock = previous_locks.rrdp.get(notify_uri);
            let target_record = store
                .get_rrdp_source_record(notify_uri)
                .map_err(|e| format!("read target rrdp source record failed for {notify_uri}: {e}"))?;

            let bucket_hash = sha256_hex(notify_uri.as_bytes());
            let bucket_dir = capture_root.join("rrdp").join("repos").join(&bucket_hash);
            let (created_at, last_seen_at) = target_record
                .as_ref()
                .map(|record| (record.first_seen_at.rfc3339_utc.clone(), record.last_seen_at.rfc3339_utc.clone()))
                .unwrap_or_else(|| {
                    let now = recorded_at
                        .format(&Rfc3339)
                        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
                    (now.clone(), now)
                });
            write_delta_repo_meta(&bucket_dir, notify_uri, &created_at, &last_seen_at)?;

            let fallback_entry = || DeltaRrdpEntryJson {
                kind: DeltaKindJson::FallbackRsync,
                base: fallback_rsync_state(),
                target: fallback_rsync_state(),
                delta_count: 0,
                deltas: Vec::new(),
            };

            let entry = if let (Some(base_lock), Some(target_record), Some(target_state)) = (
                base_lock,
                target_record.as_ref(),
                target_record.as_ref().and_then(target_rrdp_state_from_record),
            ) {
                if base_lock.transport == ReplayTransport::Rrdp
                    && base_lock.session.as_deref() == target_record.last_session_id.as_deref()
                    && target_record.last_serial == base_lock.serial
                {
                    let transition = DeltaTransitionJson {
                        kind: DeltaKindJson::Unchanged,
                        base: delta_state_from_base_lock(Some(base_lock)),
                        target: target_state.clone(),
                        delta_count: 0,
                        deltas: Vec::new(),
                    };
                    write_delta_transition(&bucket_dir, &transition)?;
                    DeltaRrdpEntryJson {
                        kind: DeltaKindJson::Unchanged,
                        base: transition.base,
                        target: transition.target,
                        delta_count: 0,
                        deltas: Vec::new(),
                    }
                } else if base_lock.transport == ReplayTransport::Rrdp
                    && base_lock.session.as_deref() == target_record.last_session_id.as_deref()
                    && target_record
                        .last_serial
                        .zip(base_lock.serial)
                        .is_some_and(|(target, base)| target > base)
                {
                    let notification_bytes = http_records
                        .get(notify_uri)
                        .map(|record| record.bytes.as_slice())
                        .ok_or_else(|| format!("missing recorded target notification body for {notify_uri}"))?;
                    let base_serial = base_lock.serial.expect("checked above");
                    let target_serial = target_record.last_serial.expect("checked above");
                    let deltas = notification_deltas_after_serial(notification_bytes, base_serial, target_serial)?;
                    let mut all_present = true;
                    let session = target_record
                        .last_session_id
                        .as_deref()
                        .ok_or_else(|| format!("missing target session for {notify_uri}"))?;
                    let session_dir = bucket_dir.join(session);
                    let notification_path =
                        session_dir.join(format!("notification-target-{target_serial}.xml"));
                    write_bytes(&notification_path, notification_bytes)?;
                    let target_notification = parse_notification(notification_bytes)
                        .map_err(|e| format!("parse target notification failed for {notify_uri}: {e}"))?;
                    let snapshot_hash_hex = hex::encode(target_notification.snapshot_hash_sha256);
                    if let Some(snapshot_bytes) = http_records
                        .get(&target_notification.snapshot_uri)
                        .map(|record| record.bytes.as_slice())
                    {
                        let snapshot_path = session_dir.join(format!(
                            "snapshot-target-{target_serial}-{snapshot_hash_hex}.xml"
                        ));
                        write_bytes(&snapshot_path, snapshot_bytes)?;
                    }
                    let deltas_dir = session_dir.join("deltas");
                    let mut delta_serials = Vec::new();
                    for dref in &deltas {
                        if let Some(delta_bytes) = http_records.get(&dref.uri).map(|record| record.bytes.as_slice()) {
                            let hash = hex::encode(dref.hash_sha256);
                            let path = deltas_dir.join(format!("delta-{}-{}.xml", dref.serial, hash));
                            write_bytes(&path, delta_bytes)?;
                            delta_serials.push(dref.serial);
                        } else {
                            all_present = false;
                            break;
                        }
                    }
                    if all_present && !delta_serials.is_empty() {
                        let transition = DeltaTransitionJson {
                            kind: DeltaKindJson::Delta,
                            base: delta_state_from_base_lock(Some(base_lock)),
                            target: target_state.clone(),
                            delta_count: delta_serials.len(),
                            deltas: delta_serials.clone(),
                        };
                        write_delta_transition(&bucket_dir, &transition)?;
                        DeltaRrdpEntryJson {
                            kind: DeltaKindJson::Delta,
                            base: transition.base,
                            target: transition.target,
                            delta_count: transition.delta_count,
                            deltas: transition.deltas,
                        }
                    } else {
                        needed_modules.insert(module_uri.clone());
                        let transition = DeltaTransitionJson {
                            kind: DeltaKindJson::FallbackRsync,
                            base: fallback_rsync_state(),
                            target: fallback_rsync_state(),
                            delta_count: 0,
                            deltas: Vec::new(),
                        };
                        write_delta_transition(&bucket_dir, &transition)?;
                        fallback_entry()
                    }
                } else {
                    needed_modules.insert(module_uri.clone());
                    let transition = DeltaTransitionJson {
                        kind: DeltaKindJson::FallbackRsync,
                        base: fallback_rsync_state(),
                        target: fallback_rsync_state(),
                        delta_count: 0,
                        deltas: Vec::new(),
                    };
                    write_delta_transition(&bucket_dir, &transition)?;
                    fallback_entry()
                }
            } else {
                needed_modules.insert(module_uri.clone());
                let transition = DeltaTransitionJson {
                    kind: DeltaKindJson::FallbackRsync,
                    base: fallback_rsync_state(),
                    target: fallback_rsync_state(),
                    delta_count: 0,
                    deltas: Vec::new(),
                };
                write_delta_transition(&bucket_dir, &transition)?;
                fallback_entry()
            };
            delta_rrdp_locks.insert(notify_uri.to_string(), entry);
        } else {
            needed_modules.insert(module_uri);
        }
    }

    for module_uri in needed_modules {
        let owned_objects;
        let objects = if let Some(objects) = rsync_objects_by_module.get(&module_uri) {
            objects
        } else {
            owned_objects = current_module_objects_from_store(store, &module_uri)?;
            &owned_objects
        };
        let (created_at, last_seen_at) = rsync_times_by_module
            .get(&module_uri)
            .cloned()
            .unwrap_or_else(|| {
                let now = recorded_at
                    .format(&Rfc3339)
                    .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
                (now.clone(), now)
            });
        let bucket_hash = sha256_hex(module_uri.as_bytes());
        let bucket_dir = capture_root.join("rsync").join("modules").join(&bucket_hash);
        materialize_rsync_module(&capture_root, &module_uri, objects, &created_at, &last_seen_at)?;
        let files = objects.keys().cloned().collect::<Vec<_>>();
        write_json(
            &bucket_dir.join("files.json"),
            &ReplayDeltaRsyncFilesJson {
                version: 1,
                module: module_uri.clone(),
                file_count: files.len(),
                files: files.clone(),
            },
        )?;
        delta_rsync_locks.insert(
            module_uri,
            DeltaRsyncEntryJson {
                file_count: files.len(),
                overlay_only: true,
            },
        );
    }

    let locks = ReplayDeltaLocksJson {
        version: 1,
        capture: capture_id.clone(),
        base_capture: previous_locks.capture.clone(),
        base_locks_sha256: previous_locks_sha256,
        validation_time: target_validation_time
            .format(&Rfc3339)
            .map_err(|e| format!("format validation time failed: {e}"))?,
        rrdp: delta_rrdp_locks,
        rsync: delta_rsync_locks,
    };
    let locks_path = step_dir.join("locks-delta.json");
    write_json(&locks_path, &locks)?;

    crate::replay::delta_archive::ReplayDeltaArchiveIndex::load(&archive_root, &locks_path)
        .map_err(|e| format!("delta archive self-validate failed: {e}"))?;

    Ok(LiveDeltaCaptureSummary {
        archive_root,
        locks_path,
        capture_id,
        rrdp_repo_count: locks.rrdp.len(),
        rsync_module_count: locks.rsync.len(),
    })
}

pub fn write_live_delta_replay_bundle_inputs(
    rir_dir: &Path,
    rir: &str,
    target_validation_time: time::OffsetDateTime,
    publication_points: &[PublicationPointAudit],
    store: &RocksStore,
    http_records: &BTreeMap<String, RecordedHttpResponse>,
    rsync_records: &BTreeMap<String, RecordedRsyncFetch>,
) -> Result<LiveDeltaCaptureSummary, String> {
    let base_archive_root = rir_dir.join("base-payload-archive");
    let base_locks_path = rir_dir.join("base-locks.json");
    let base_index = ReplayArchiveIndex::load_allow_missing_rsync_modules(&base_archive_root, &base_locks_path)
        .map_err(|e| format!("load base replay index failed: {e}"))?;
    let base_locks_bytes = fs::read(&base_locks_path)
        .map_err(|e| format!("read base locks failed: {}: {e}", base_locks_path.display()))?;
    let base_locks_sha256 = sha256_hex(&base_locks_bytes);

    let recorded_at = time::OffsetDateTime::now_utc();
    let capture_id = format!(
        "{rir}-delta-{}",
        recorded_at
            .format(&Rfc3339)
            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
            .replace(':', "")
            .replace('-', "")
            .replace('+', "_")
    );
    let archive_root = rir_dir.join("payload-delta-archive");
    let capture_root = archive_root.join("v1").join("captures").join(&capture_id);
    write_json(
        &capture_root.join("capture.json"),
        &CaptureMetaJson {
            version: 1,
            capture_id: capture_id.clone(),
            created_at: recorded_at
                .format(&Rfc3339)
                .map_err(|e| format!("format createdAt failed: {e}"))?,
            notes: format!("recorded by ours live delta recorder for {rir}"),
        },
    )?;
    write_json(
        &capture_root.join("base.json"),
        &DeltaBaseMetaJson {
            version: 1,
            base_capture: base_index.locks.capture.clone(),
            base_locks_sha256: base_locks_sha256.clone(),
            created_at: recorded_at
                .format(&Rfc3339)
                .map_err(|e| format!("format createdAt failed: {e}"))?,
        },
    )?;

    let mut rsync_objects_by_module: BTreeMap<String, BTreeMap<String, Vec<u8>>> = BTreeMap::new();
    let mut rsync_times_by_module: BTreeMap<String, (String, String)> = BTreeMap::new();
    for fetch in rsync_records.values() {
        let module_uri = canonical_rsync_module(&fetch.requested_base_uri)
            .map_err(|e| format!("canonicalize requested rsync module failed: {e}"))?;
        let objects = rsync_objects_by_module.entry(module_uri.clone()).or_default();
        let times = rsync_times_by_module
            .entry(module_uri)
            .or_insert_with(|| (fetch.fetched_at_rfc3339_utc.clone(), fetch.fetched_at_rfc3339_utc.clone()));
        if fetch.fetched_at_rfc3339_utc < times.0 {
            times.0 = fetch.fetched_at_rfc3339_utc.clone();
        }
        if fetch.fetched_at_rfc3339_utc > times.1 {
            times.1 = fetch.fetched_at_rfc3339_utc.clone();
        }
        for (uri, bytes) in &fetch.objects {
            objects.insert(uri.clone(), bytes.clone());
        }
    }

    let mut delta_rrdp_locks = BTreeMap::new();
    let mut delta_rsync_locks = BTreeMap::new();
    let mut seen_notifications = BTreeSet::new();
    let mut needed_modules = BTreeSet::new();

    for pp in publication_points {
        let module_uri = canonical_rsync_module(&pp.rsync_base_uri)
            .map_err(|e| format!("canonicalize rsync module failed for {}: {e}", pp.rsync_base_uri))?;
        if let Some(notify_uri) = pp.rrdp_notification_uri.as_deref() {
            if !seen_notifications.insert(notify_uri.to_string()) {
                continue;
            }
            let base_lock = base_index.rrdp_lock(notify_uri);
            let target_record = store
                .get_rrdp_source_record(notify_uri)
                .map_err(|e| format!("read target rrdp source record failed for {notify_uri}: {e}"))?;

            let bucket_hash = sha256_hex(notify_uri.as_bytes());
            let bucket_dir = capture_root.join("rrdp").join("repos").join(&bucket_hash);
            let (created_at, last_seen_at) = target_record
                .as_ref()
                .map(|record| (record.first_seen_at.rfc3339_utc.clone(), record.last_seen_at.rfc3339_utc.clone()))
                .unwrap_or_else(|| {
                    let now = recorded_at
                        .format(&Rfc3339)
                        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
                    (now.clone(), now)
                });
            write_delta_repo_meta(&bucket_dir, notify_uri, &created_at, &last_seen_at)?;

            let fallback_entry = || DeltaRrdpEntryJson {
                kind: DeltaKindJson::FallbackRsync,
                base: fallback_rsync_state(),
                target: fallback_rsync_state(),
                delta_count: 0,
                deltas: Vec::new(),
            };

            let entry = if let (Some(base_lock), Some(target_record), Some(target_state)) = (
                base_lock,
                target_record.as_ref(),
                target_record
                    .as_ref()
                    .and_then(target_rrdp_state_from_record),
            ) {
                if base_lock.transport == ReplayTransport::Rrdp
                    && base_lock.session.as_deref() == target_record.last_session_id.as_deref()
                    && target_record.last_serial == base_lock.serial
                {
                    let transition = DeltaTransitionJson {
                        kind: DeltaKindJson::Unchanged,
                        base: delta_state_from_base_lock(Some(base_lock)),
                        target: target_state.clone(),
                        delta_count: 0,
                        deltas: Vec::new(),
                    };
                    write_delta_transition(&bucket_dir, &transition)?;
                    DeltaRrdpEntryJson {
                        kind: DeltaKindJson::Unchanged,
                        base: transition.base,
                        target: transition.target,
                        delta_count: 0,
                        deltas: Vec::new(),
                    }
                } else if base_lock.transport == ReplayTransport::Rrdp
                    && base_lock.session.as_deref() == target_record.last_session_id.as_deref()
                    && target_record
                        .last_serial
                        .zip(base_lock.serial)
                        .is_some_and(|(target, base)| target > base)
                {
                    let notification_bytes = http_records
                        .get(notify_uri)
                        .map(|record| record.bytes.as_slice())
                        .ok_or_else(|| format!("missing recorded target notification body for {notify_uri}"))?;
                    let base_serial = base_lock.serial.expect("checked above");
                    let target_serial = target_record.last_serial.expect("checked above");
                    let deltas = notification_deltas_after_serial(notification_bytes, base_serial, target_serial)?;
                    let mut all_present = true;
                    let session = target_record
                        .last_session_id
                        .as_deref()
                        .ok_or_else(|| format!("missing target session for {notify_uri}"))?;
                    let session_dir = bucket_dir.join(session);
                    let notification_path = session_dir.join(format!("notification-target-{target_serial}.xml"));
                    write_bytes(&notification_path, notification_bytes)?;
                    let target_notification = parse_notification(notification_bytes)
                        .map_err(|e| format!("parse target notification failed for {notify_uri}: {e}"))?;
                    let snapshot_hash_hex = hex::encode(target_notification.snapshot_hash_sha256);
                    if let Some(snapshot_bytes) = http_records
                        .get(&target_notification.snapshot_uri)
                        .map(|record| record.bytes.as_slice())
                    {
                        let snapshot_path = session_dir.join(format!(
                            "snapshot-target-{target_serial}-{snapshot_hash_hex}.xml"
                        ));
                        write_bytes(&snapshot_path, snapshot_bytes)?;
                    }
                    let deltas_dir = session_dir.join("deltas");
                    let mut delta_serials = Vec::new();
                    for dref in &deltas {
                        if let Some(delta_bytes) = http_records.get(&dref.uri).map(|record| record.bytes.as_slice()) {
                            let hash = hex::encode(dref.hash_sha256);
                            let path = deltas_dir.join(format!("delta-{}-{}.xml", dref.serial, hash));
                            write_bytes(&path, delta_bytes)?;
                            delta_serials.push(dref.serial);
                        } else {
                            all_present = false;
                            break;
                        }
                    }
                    if all_present && !delta_serials.is_empty() {
                        let transition = DeltaTransitionJson {
                            kind: DeltaKindJson::Delta,
                            base: delta_state_from_base_lock(Some(base_lock)),
                            target: target_state.clone(),
                            delta_count: delta_serials.len(),
                            deltas: delta_serials.clone(),
                        };
                        write_delta_transition(&bucket_dir, &transition)?;
                        DeltaRrdpEntryJson {
                            kind: DeltaKindJson::Delta,
                            base: transition.base,
                            target: transition.target,
                            delta_count: transition.delta_count,
                            deltas: transition.deltas,
                        }
                    } else {
                        needed_modules.insert(module_uri.clone());
                        let transition = DeltaTransitionJson {
                            kind: DeltaKindJson::FallbackRsync,
                            base: fallback_rsync_state(),
                            target: fallback_rsync_state(),
                            delta_count: 0,
                            deltas: Vec::new(),
                        };
                        write_delta_transition(&bucket_dir, &transition)?;
                        fallback_entry()
                    }
                } else {
                    needed_modules.insert(module_uri.clone());
                    let transition = DeltaTransitionJson {
                        kind: DeltaKindJson::FallbackRsync,
                        base: fallback_rsync_state(),
                        target: fallback_rsync_state(),
                        delta_count: 0,
                        deltas: Vec::new(),
                    };
                    write_delta_transition(&bucket_dir, &transition)?;
                    fallback_entry()
                }
            } else {
                needed_modules.insert(module_uri.clone());
                let transition = DeltaTransitionJson {
                    kind: DeltaKindJson::FallbackRsync,
                    base: fallback_rsync_state(),
                    target: fallback_rsync_state(),
                    delta_count: 0,
                    deltas: Vec::new(),
                };
                write_delta_transition(&bucket_dir, &transition)?;
                fallback_entry()
            };
            delta_rrdp_locks.insert(notify_uri.to_string(), entry);
        } else {
            needed_modules.insert(module_uri);
        }
    }

    for module_uri in needed_modules {
        let owned_objects;
        let objects = if let Some(objects) = rsync_objects_by_module.get(&module_uri) {
            objects
        } else {
            owned_objects = current_module_objects_from_store(store, &module_uri)?;
            &owned_objects
        };
        let (created_at, last_seen_at) = rsync_times_by_module
            .get(&module_uri)
            .cloned()
            .unwrap_or_else(|| {
                let now = recorded_at
                    .format(&Rfc3339)
                    .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
                (now.clone(), now)
            });
        let bucket_hash = sha256_hex(module_uri.as_bytes());
        let bucket_dir = capture_root.join("rsync").join("modules").join(&bucket_hash);
        materialize_rsync_module(&capture_root, &module_uri, objects, &created_at, &last_seen_at)?;
        let files = objects.keys().cloned().collect::<Vec<_>>();
        write_json(
            &bucket_dir.join("files.json"),
            &ReplayDeltaRsyncFilesJson {
                version: 1,
                module: module_uri.clone(),
                file_count: files.len(),
                files: files.clone(),
            },
        )?;
        delta_rsync_locks.insert(
            module_uri,
            DeltaRsyncEntryJson {
                file_count: files.len(),
                overlay_only: true,
            },
        );
    }

    let locks = ReplayDeltaLocksJson {
        version: 1,
        capture: capture_id.clone(),
        base_capture: base_index.locks.capture.clone(),
        base_locks_sha256: base_locks_sha256.clone(),
        validation_time: target_validation_time
            .format(&Rfc3339)
            .map_err(|e| format!("format validation time failed: {e}"))?,
        rrdp: delta_rrdp_locks,
        rsync: delta_rsync_locks,
    };
    let locks_path = rir_dir.join("locks-delta.json");
    write_json(&locks_path, &locks)?;

    crate::replay::delta_archive::ReplayDeltaArchiveIndex::load(&archive_root, &locks_path)
        .map_err(|e| format!("delta archive self-validate failed: {e}"))?;

    Ok(LiveDeltaCaptureSummary {
        archive_root,
        locks_path,
        capture_id,
        rrdp_repo_count: locks.rrdp.len(),
        rsync_module_count: locks.rsync.len(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::fetch::rsync::RsyncFetcher;
    use crate::storage::{PackTime, RrdpSourceRecord, RrdpSourceSyncState};
    use crate::sync::rrdp::Fetcher;

    #[derive(Clone)]
    struct DummyHttpFetcher {
        map: BTreeMap<String, Vec<u8>>,
    }

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

    #[derive(Clone)]
    struct DummyRsyncFetcher {
        objects: Vec<(String, Vec<u8>)>,
    }

    impl RsyncFetcher for DummyRsyncFetcher {
        fn fetch_objects(
            &self,
            _rsync_base_uri: &str,
        ) -> Result<Vec<(String, Vec<u8>)>, RsyncFetchError> {
            Ok(self.objects.clone())
        }
    }

    fn minimal_notification(notify_uri: &str, snapshot_uri: &str, session: &str, serial: u64) -> Vec<u8> {
        format!(
            r#"<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session}" serial="{serial}"><snapshot uri="{snapshot_uri}" hash="{}"/></notification>"#,
            sha256_hex(b"<snapshot/>")
        )
        .replace("notification.xml", notify_uri)
        .into_bytes()
    }

    #[test]
    fn recording_http_fetcher_records_successful_responses() {
        let fetcher = RecordingHttpFetcher::new(DummyHttpFetcher {
            map: BTreeMap::from([("https://example.test/a".to_string(), b"abc".to_vec())]),
        });
        let got = fetcher.fetch("https://example.test/a").expect("fetch");
        assert_eq!(got, b"abc");
        let snapshot = fetcher.snapshot_responses();
        assert_eq!(snapshot.len(), 1);
        assert_eq!(snapshot["https://example.test/a"].bytes, b"abc");
    }

    #[test]
    fn recording_rsync_fetcher_records_object_sets() {
        let fetcher = RecordingRsyncFetcher::new(DummyRsyncFetcher {
            objects: vec![("rsync://example.test/repo/a.roa".to_string(), b"roa".to_vec())],
        });
        let got = fetcher
            .fetch_objects("rsync://example.test/repo/")
            .expect("fetch objects");
        assert_eq!(got.len(), 1);
        let snapshot = fetcher.snapshot_fetches();
        assert_eq!(snapshot.len(), 1);
        assert_eq!(
            snapshot["rsync://example.test/repo/"].objects[0].0,
            "rsync://example.test/repo/a.roa"
        );
    }

    #[test]
    fn write_live_base_replay_bundle_inputs_materializes_archive_and_locks() {
        let td = tempfile::tempdir().expect("tempdir");
        let rir_dir = td.path().join("apnic");
        std::fs::create_dir_all(&rir_dir).expect("mkdir");
        let store = RocksStore::open(&td.path().join("db")).expect("open rocksdb");

        let notify_uri = "https://rrdp.example.test/notification.xml";
        let snapshot_uri = "https://rrdp.example.test/snapshot.xml";
        let session = "11111111-1111-1111-1111-111111111111";
        let serial = 42u64;
        store
            .put_rrdp_source_record(&RrdpSourceRecord {
                notify_uri: notify_uri.to_string(),
                last_session_id: Some(session.to_string()),
                last_serial: Some(serial),
                first_seen_at: PackTime {
                    rfc3339_utc: "2026-03-30T00:00:00Z".to_string(),
                },
                last_seen_at: PackTime {
                    rfc3339_utc: "2026-03-30T00:00:01Z".to_string(),
                },
                last_sync_at: Some(PackTime {
                    rfc3339_utc: "2026-03-30T00:00:01Z".to_string(),
                }),
                sync_state: RrdpSourceSyncState::SnapshotOnly,
                last_snapshot_uri: Some(snapshot_uri.to_string()),
                last_snapshot_hash: Some(sha256_hex(b"<snapshot/>")),
                last_error: None,
            })
            .expect("put source");

        let publication_points = vec![
            PublicationPointAudit {
                rsync_base_uri: "rsync://rsync.example.test/repo/".to_string(),
                manifest_rsync_uri: "rsync://rsync.example.test/repo/manifest.mft".to_string(),
                publication_point_rsync_uri: "rsync://rsync.example.test/repo/".to_string(),
                rrdp_notification_uri: Some(notify_uri.to_string()),
                source: "fresh".to_string(),
                repo_sync_source: None,
                repo_sync_duration_ms: None,
                repo_sync_error: None,
                this_update_rfc3339_utc: "2026-03-30T00:00:00Z".to_string(),
                next_update_rfc3339_utc: "2026-03-30T01:00:00Z".to_string(),
                verified_at_rfc3339_utc: "2026-03-30T00:00:02Z".to_string(),
                warnings: Vec::new(),
                objects: Vec::new(),
                node_id: None,
                parent_node_id: None,
                discovered_from: None,
            },
            PublicationPointAudit {
                rsync_base_uri: "rsync://rsync-only.example.test/repo/".to_string(),
                manifest_rsync_uri: "rsync://rsync-only.example.test/repo/manifest.mft".to_string(),
                publication_point_rsync_uri: "rsync://rsync-only.example.test/repo/".to_string(),
                rrdp_notification_uri: None,
                source: "fresh".to_string(),
                repo_sync_source: None,
                repo_sync_duration_ms: None,
                repo_sync_error: None,
                this_update_rfc3339_utc: "2026-03-30T00:00:00Z".to_string(),
                next_update_rfc3339_utc: "2026-03-30T01:00:00Z".to_string(),
                verified_at_rfc3339_utc: "2026-03-30T00:00:02Z".to_string(),
                warnings: Vec::new(),
                objects: Vec::new(),
                node_id: None,
                parent_node_id: None,
                discovered_from: None,
            },
        ];

        let http_records = BTreeMap::from([
            (
                notify_uri.to_string(),
                RecordedHttpResponse {
                    uri: notify_uri.to_string(),
                    bytes: minimal_notification(notify_uri, snapshot_uri, session, serial),
                    fetched_at_rfc3339_utc: "2026-03-30T00:00:00Z".to_string(),
                },
            ),
            (
                snapshot_uri.to_string(),
                RecordedHttpResponse {
                    uri: snapshot_uri.to_string(),
                    bytes: b"<snapshot/>".to_vec(),
                    fetched_at_rfc3339_utc: "2026-03-30T00:00:01Z".to_string(),
                },
            ),
        ]);
        let rsync_records = BTreeMap::from([(
            "rsync://rsync-only.example.test/repo/".to_string(),
            RecordedRsyncFetch {
                requested_base_uri: "rsync://rsync-only.example.test/repo/".to_string(),
                objects: vec![(
                    "rsync://rsync-only.example.test/repo/a.roa".to_string(),
                    b"roa".to_vec(),
                )],
                fetched_at_rfc3339_utc: "2026-03-30T00:00:02Z".to_string(),
            },
        )]);

        let summary = write_live_base_replay_bundle_inputs(
            &rir_dir,
            "apnic",
            time::OffsetDateTime::parse("2026-03-30T00:00:03Z", &Rfc3339).expect("time"),
            &publication_points,
            &store,
            &http_records,
            &rsync_records,
        )
        .expect("write live bundle inputs");

        assert!(summary.archive_root.is_dir());
        assert!(summary.locks_path.is_file());
        let locks: serde_json::Value =
            serde_json::from_slice(&std::fs::read(&summary.locks_path).expect("read locks"))
                .expect("parse locks");
        assert_eq!(locks["validationTime"], "2026-03-30T00:00:03Z");
        assert_eq!(locks["rrdp"][notify_uri]["transport"], "rrdp");
        assert_eq!(
            locks["rsync"]["rsync://rsync-only.example.test/repo/"]["transport"],
            "rsync"
        );

        let index = ReplayArchiveIndex::load_allow_missing_rsync_modules(
            rir_dir.join("base-payload-archive"),
            rir_dir.join("base-locks.json"),
        )
        .expect("load emitted replay archive");
        assert!(index.rrdp_repo(notify_uri).is_some());
        assert!(
            index
                .resolve_rsync_module_for_base_uri("rsync://rsync-only.example.test/repo/sub")
                .is_ok()
        );
    }
}
