use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use serde::Deserialize;

use crate::replay::archive::{
    ReplayArchiveError, ReplayRrdpRepoMeta, ReplayRsyncModuleMeta, ReplayTransport,
    canonical_rsync_module, sha256_hex,
};

#[derive(Debug, thiserror::Error)]
pub enum ReplayDeltaArchiveError {
    #[error(transparent)]
    Base(#[from] ReplayArchiveError),

    #[error("delta capture directory not found: {0}")]
    MissingDeltaCaptureDirectory(String),

    #[error("delta capture.json captureId mismatch: locks={locks_capture}, capture={capture_json}")]
    CaptureIdMismatch {
        locks_capture: String,
        capture_json: String,
    },

    #[error(
        "delta base.json baseCapture mismatch: locks={locks_base_capture}, base_json={base_json_base_capture}"
    )]
    BaseCaptureMismatch {
        locks_base_capture: String,
        base_json_base_capture: String,
    },

    #[error(
        "delta base.json baseLocksSha256 mismatch: locks={locks_sha256}, base_json={base_json_sha256}"
    )]
    BaseLocksShaMismatch {
        locks_sha256: String,
        base_json_sha256: String,
    },

    #[error("base locks sha256 mismatch: expected {expected}, actual {actual}")]
    BaseLocksBytesShaMismatch { expected: String, actual: String },

    #[error("delta repo bucket not found for {notify_uri}: {path}")]
    MissingDeltaRepoBucket { notify_uri: String, path: String },

    #[error("delta repo meta mismatch: expected {expected}, actual {actual}")]
    RrdpMetaMismatch { expected: String, actual: String },

    #[error(
        "delta transition kind mismatch for {notify_uri}: locks={locks_kind}, transition={transition_kind}"
    )]
    TransitionKindMismatch {
        notify_uri: String,
        locks_kind: String,
        transition_kind: String,
    },

    #[error("delta transition base mismatch for {notify_uri}")]
    TransitionBaseMismatch { notify_uri: String },

    #[error("delta transition target mismatch for {notify_uri}")]
    TransitionTargetMismatch { notify_uri: String },

    #[error("delta serial list mismatch for {notify_uri}")]
    DeltaSerialListMismatch { notify_uri: String },

    #[error("delta notification session directory not found for {notify_uri}: {path}")]
    MissingDeltaSessionDir { notify_uri: String, path: String },

    #[error("target notification file not found for {notify_uri}: {path}")]
    MissingTargetNotification { notify_uri: String, path: String },

    #[error("delta file not found for {notify_uri} serial={serial}: {path}")]
    MissingDeltaFile {
        notify_uri: String,
        serial: u64,
        path: String,
    },

    #[error("delta target archive missing for {notify_uri}: {path}")]
    MissingTargetArchive { notify_uri: String, path: String },

    #[error("delta rsync module bucket not found for {module_uri}: {path}")]
    MissingRsyncModuleBucket { module_uri: String, path: String },

    #[error("delta rsync module meta mismatch: expected {expected}, actual {actual}")]
    RsyncMetaMismatch { expected: String, actual: String },

    #[error("delta rsync files.json module mismatch: expected {expected}, actual {actual}")]
    RsyncFilesModuleMismatch { expected: String, actual: String },

    #[error(
        "delta rsync file count mismatch for {module_uri}: declared={declared}, actual={actual}"
    )]
    RsyncFileCountMismatch {
        module_uri: String,
        declared: usize,
        actual: usize,
    },

    #[error("delta rsync overlay file not found for {module_uri}: {path}")]
    MissingRsyncOverlayFile { module_uri: String, path: String },
}

#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct ReplayDeltaLocks {
    pub version: u32,
    pub capture: String,
    #[serde(rename = "baseCapture")]
    pub base_capture: String,
    #[serde(rename = "baseLocksSha256")]
    pub base_locks_sha256: String,
    pub rrdp: BTreeMap<String, ReplayDeltaRrdpEntry>,
    pub rsync: BTreeMap<String, ReplayDeltaRsyncEntry>,
}

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

#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReplayDeltaRrdpKind {
    Unchanged,
    Delta,
    FallbackRsync,
    SessionReset,
    Gap,
}

impl ReplayDeltaRrdpKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Unchanged => "unchanged",
            Self::Delta => "delta",
            Self::FallbackRsync => "fallback-rsync",
            Self::SessionReset => "session-reset",
            Self::Gap => "gap",
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct ReplayDeltaRrdpState {
    pub transport: ReplayTransport,
    pub session: Option<String>,
    pub serial: Option<u64>,
}

#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct ReplayDeltaRrdpEntry {
    pub kind: ReplayDeltaRrdpKind,
    pub base: ReplayDeltaRrdpState,
    pub target: ReplayDeltaRrdpState,
    #[serde(rename = "delta_count")]
    pub delta_count: usize,
    pub deltas: Vec<u64>,
}

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

#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct ReplayDeltaTransition {
    pub kind: ReplayDeltaRrdpKind,
    pub base: ReplayDeltaRrdpState,
    pub target: ReplayDeltaRrdpState,
    #[serde(rename = "delta_count")]
    pub delta_count: usize,
    pub deltas: Vec<u64>,
}

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

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReplayDeltaRrdpRepo {
    pub notify_uri: String,
    pub bucket_hash: String,
    pub bucket_dir: PathBuf,
    pub meta: ReplayRrdpRepoMeta,
    pub transition: ReplayDeltaTransition,
    pub target_notification_path: Option<PathBuf>,
    pub delta_paths: Vec<(u64, PathBuf)>,
    pub target_archive_path: Option<PathBuf>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReplayDeltaRsyncModule {
    pub module_uri: String,
    pub bucket_hash: String,
    pub bucket_dir: PathBuf,
    pub meta: ReplayRsyncModuleMeta,
    pub overlay_only: bool,
    pub files: ReplayDeltaRsyncFiles,
    pub tree_dir: PathBuf,
    pub overlay_files: Vec<(String, PathBuf)>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReplayDeltaArchiveIndex {
    pub archive_root: PathBuf,
    pub capture_root: PathBuf,
    pub delta_locks_path: PathBuf,
    pub delta_locks: ReplayDeltaLocks,
    pub capture_meta: crate::replay::archive::ReplayCaptureMeta,
    pub base_meta: ReplayDeltaBaseMeta,
    pub rrdp_repos: BTreeMap<String, ReplayDeltaRrdpRepo>,
    pub rsync_modules: BTreeMap<String, ReplayDeltaRsyncModule>,
}

impl ReplayDeltaArchiveIndex {
    pub fn load(
        delta_archive_root: impl AsRef<Path>,
        delta_locks_path: impl AsRef<Path>,
    ) -> Result<Self, ReplayDeltaArchiveError> {
        let archive_root = delta_archive_root.as_ref().to_path_buf();
        let delta_locks_path = delta_locks_path.as_ref().to_path_buf();

        let delta_locks: ReplayDeltaLocks =
            read_delta_json_file(&delta_locks_path, "payload delta locks")?;
        ensure_delta_version("payload delta locks", delta_locks.version)?;

        let capture_root = archive_root
            .join("v1")
            .join("captures")
            .join(&delta_locks.capture);
        if !capture_root.is_dir() {
            return Err(ReplayDeltaArchiveError::MissingDeltaCaptureDirectory(
                capture_root.display().to_string(),
            ));
        }

        let capture_meta: crate::replay::archive::ReplayCaptureMeta =
            read_delta_json_file(&capture_root.join("capture.json"), "delta capture meta")?;
        ensure_delta_version("delta capture meta", capture_meta.version)?;
        if capture_meta.capture_id != delta_locks.capture {
            return Err(ReplayDeltaArchiveError::CaptureIdMismatch {
                locks_capture: delta_locks.capture.clone(),
                capture_json: capture_meta.capture_id.clone(),
            });
        }

        let base_meta: ReplayDeltaBaseMeta =
            read_delta_json_file(&capture_root.join("base.json"), "delta base meta")?;
        ensure_delta_version("delta base meta", base_meta.version)?;
        if base_meta.base_capture != delta_locks.base_capture {
            return Err(ReplayDeltaArchiveError::BaseCaptureMismatch {
                locks_base_capture: delta_locks.base_capture.clone(),
                base_json_base_capture: base_meta.base_capture.clone(),
            });
        }
        if base_meta.base_locks_sha256.to_ascii_lowercase()
            != delta_locks.base_locks_sha256.to_ascii_lowercase()
        {
            return Err(ReplayDeltaArchiveError::BaseLocksShaMismatch {
                locks_sha256: delta_locks.base_locks_sha256.clone(),
                base_json_sha256: base_meta.base_locks_sha256.clone(),
            });
        }

        let mut rrdp_repos = BTreeMap::new();
        for (notify_uri, entry) in &delta_locks.rrdp {
            let repo = load_delta_rrdp_repo(&capture_root, notify_uri, entry)?;
            rrdp_repos.insert(notify_uri.clone(), repo);
        }

        let mut rsync_modules = BTreeMap::new();
        for (module_uri, entry) in &delta_locks.rsync {
            let module = load_delta_rsync_module(&capture_root, module_uri, entry)?;
            rsync_modules.insert(module.module_uri.clone(), module);
        }

        Ok(Self {
            archive_root,
            capture_root,
            delta_locks_path,
            delta_locks,
            capture_meta,
            base_meta,
            rrdp_repos,
            rsync_modules,
        })
    }

    pub fn rrdp_repo(&self, notify_uri: &str) -> Option<&ReplayDeltaRrdpRepo> {
        self.rrdp_repos.get(notify_uri)
    }

    pub fn rsync_module(&self, module_uri: &str) -> Option<&ReplayDeltaRsyncModule> {
        self.rsync_modules.get(module_uri)
    }

    pub fn resolve_rsync_module_for_base_uri(
        &self,
        rsync_base_uri: &str,
    ) -> Result<&ReplayDeltaRsyncModule, ReplayDeltaArchiveError> {
        let module_uri =
            canonical_rsync_module(rsync_base_uri).map_err(ReplayDeltaArchiveError::Base)?;
        self.rsync_modules.get(&module_uri).ok_or_else(|| {
            ReplayDeltaArchiveError::MissingRsyncModuleBucket {
                module_uri,
                path: "<not indexed>".to_string(),
            }
        })
    }

    pub fn validate_base_locks_sha256_bytes(
        &self,
        base_locks_bytes: &[u8],
    ) -> Result<(), ReplayDeltaArchiveError> {
        let actual = sha256_hex(base_locks_bytes);
        let expected = self.delta_locks.base_locks_sha256.to_ascii_lowercase();
        if actual != expected {
            return Err(ReplayDeltaArchiveError::BaseLocksBytesShaMismatch { expected, actual });
        }
        Ok(())
    }

    pub fn validate_base_locks_sha256_file(
        &self,
        path: &Path,
    ) -> Result<(), ReplayDeltaArchiveError> {
        let bytes = fs::read(path).map_err(|e| {
            ReplayDeltaArchiveError::Base(ReplayArchiveError::ReadFile {
                entity: "base locks file",
                path: path.display().to_string(),
                detail: e.to_string(),
            })
        })?;
        self.validate_base_locks_sha256_bytes(&bytes)
    }
}

fn load_delta_rrdp_repo(
    capture_root: &Path,
    notify_uri: &str,
    entry: &ReplayDeltaRrdpEntry,
) -> Result<ReplayDeltaRrdpRepo, ReplayDeltaArchiveError> {
    let bucket_hash = sha256_hex(notify_uri.as_bytes());
    let bucket_dir = capture_root.join("rrdp").join("repos").join(&bucket_hash);
    if !bucket_dir.is_dir() {
        return Err(ReplayDeltaArchiveError::MissingDeltaRepoBucket {
            notify_uri: notify_uri.to_string(),
            path: bucket_dir.display().to_string(),
        });
    }

    let meta: ReplayRrdpRepoMeta =
        read_delta_json_file(&bucket_dir.join("meta.json"), "delta RRDP repo meta")?;
    ensure_delta_version("delta RRDP repo meta", meta.version)?;
    if meta.rpki_notify != notify_uri {
        return Err(ReplayDeltaArchiveError::RrdpMetaMismatch {
            expected: notify_uri.to_string(),
            actual: meta.rpki_notify.clone(),
        });
    }

    let transition: ReplayDeltaTransition =
        read_delta_json_file(&bucket_dir.join("transition.json"), "delta transition")?;
    if transition.kind != entry.kind {
        return Err(ReplayDeltaArchiveError::TransitionKindMismatch {
            notify_uri: notify_uri.to_string(),
            locks_kind: entry.kind.as_str().to_string(),
            transition_kind: transition.kind.as_str().to_string(),
        });
    }
    if transition.base != entry.base {
        return Err(ReplayDeltaArchiveError::TransitionBaseMismatch {
            notify_uri: notify_uri.to_string(),
        });
    }
    if transition.target != entry.target {
        return Err(ReplayDeltaArchiveError::TransitionTargetMismatch {
            notify_uri: notify_uri.to_string(),
        });
    }
    if transition.delta_count != entry.delta_count || transition.deltas != entry.deltas {
        return Err(ReplayDeltaArchiveError::DeltaSerialListMismatch {
            notify_uri: notify_uri.to_string(),
        });
    }

    let (target_notification_path, delta_paths, target_archive_path) = match entry.kind {
        ReplayDeltaRrdpKind::Delta => {
            let session = entry.target.session.as_ref().ok_or_else(|| {
                ReplayDeltaArchiveError::TransitionTargetMismatch {
                    notify_uri: notify_uri.to_string(),
                }
            })?;
            let serial = entry.target.serial.ok_or_else(|| {
                ReplayDeltaArchiveError::TransitionTargetMismatch {
                    notify_uri: notify_uri.to_string(),
                }
            })?;
            let session_dir = bucket_dir.join(session);
            if !session_dir.is_dir() {
                return Err(ReplayDeltaArchiveError::MissingDeltaSessionDir {
                    notify_uri: notify_uri.to_string(),
                    path: session_dir.display().to_string(),
                });
            }
            let notification = session_dir.join(format!("notification-target-{serial}.xml"));
            if !notification.is_file() {
                return Err(ReplayDeltaArchiveError::MissingTargetNotification {
                    notify_uri: notify_uri.to_string(),
                    path: notification.display().to_string(),
                });
            }
            let mut delta_paths = Vec::new();
            for delta_serial in &entry.deltas {
                let pattern = format!("delta-{delta_serial}-");
                let deltas_dir = session_dir.join("deltas");
                let mut matches = if deltas_dir.is_dir() {
                    fs::read_dir(&deltas_dir)
                        .map_err(|e| {
                            ReplayDeltaArchiveError::Base(ReplayArchiveError::ReadFile {
                                entity: "delta deltas dir",
                                path: deltas_dir.display().to_string(),
                                detail: e.to_string(),
                            })
                        })?
                        .filter_map(|entry| entry.ok().map(|e| e.path()))
                        .filter(|path| path.is_file())
                        .filter(|path| {
                            path.file_name()
                                .and_then(|n| n.to_str())
                                .is_some_and(|n| n.starts_with(&pattern) && n.ends_with(".xml"))
                        })
                        .collect::<Vec<_>>()
                } else {
                    Vec::new()
                };
                matches.sort();
                let path = matches.into_iter().next().ok_or_else(|| {
                    ReplayDeltaArchiveError::MissingDeltaFile {
                        notify_uri: notify_uri.to_string(),
                        serial: *delta_serial,
                        path: deltas_dir
                            .join(format!("delta-{delta_serial}-<hash>.xml"))
                            .display()
                            .to_string(),
                    }
                })?;
                delta_paths.push((*delta_serial, path));
            }
            let target_archive = bucket_dir.join(format!("target-archive-{serial}.bin"));
            let target_archive_path = if target_archive.is_file() {
                Some(target_archive)
            } else {
                None
            };
            (Some(notification), delta_paths, target_archive_path)
        }
        ReplayDeltaRrdpKind::Unchanged | ReplayDeltaRrdpKind::FallbackRsync => {
            (None, Vec::new(), None)
        }
        ReplayDeltaRrdpKind::SessionReset | ReplayDeltaRrdpKind::Gap => (None, Vec::new(), None),
    };

    Ok(ReplayDeltaRrdpRepo {
        notify_uri: notify_uri.to_string(),
        bucket_hash,
        bucket_dir,
        meta,
        transition,
        target_notification_path,
        delta_paths,
        target_archive_path,
    })
}

fn load_delta_rsync_module(
    capture_root: &Path,
    module_uri: &str,
    entry: &ReplayDeltaRsyncEntry,
) -> Result<ReplayDeltaRsyncModule, ReplayDeltaArchiveError> {
    let canonical = canonical_rsync_module(module_uri).map_err(ReplayDeltaArchiveError::Base)?;
    let bucket_hash = sha256_hex(canonical.as_bytes());
    let bucket_dir = capture_root
        .join("rsync")
        .join("modules")
        .join(&bucket_hash);
    if !bucket_dir.is_dir() {
        return Err(ReplayDeltaArchiveError::MissingRsyncModuleBucket {
            module_uri: canonical.clone(),
            path: bucket_dir.display().to_string(),
        });
    }

    let meta_path = bucket_dir.join("meta.json");
    let meta: ReplayRsyncModuleMeta = if meta_path.is_file() {
        let meta: ReplayRsyncModuleMeta =
            read_delta_json_file(&meta_path, "delta rsync module meta")?;
        ensure_delta_version("delta rsync module meta", meta.version)?;
        if meta.module != canonical {
            return Err(ReplayDeltaArchiveError::RsyncMetaMismatch {
                expected: canonical.clone(),
                actual: meta.module.clone(),
            });
        }
        meta
    } else {
        ReplayRsyncModuleMeta {
            version: 1,
            module: canonical.clone(),
            created_at: String::new(),
            last_seen_at: String::new(),
        }
    };

    let files: ReplayDeltaRsyncFiles =
        read_delta_json_file(&bucket_dir.join("files.json"), "delta rsync files")?;
    ensure_delta_version("delta rsync files", files.version)?;
    if files.module != canonical {
        return Err(ReplayDeltaArchiveError::RsyncFilesModuleMismatch {
            expected: canonical.clone(),
            actual: files.module.clone(),
        });
    }
    if files.file_count != entry.file_count || files.file_count != files.files.len() {
        return Err(ReplayDeltaArchiveError::RsyncFileCountMismatch {
            module_uri: canonical.clone(),
            declared: entry.file_count,
            actual: files.files.len(),
        });
    }

    let tree_dir = bucket_dir.join("tree");
    let mut overlay_files = Vec::new();
    for uri in &files.files {
        let rel = uri.strip_prefix(&canonical).ok_or_else(|| {
            ReplayDeltaArchiveError::RsyncFilesModuleMismatch {
                expected: canonical.clone(),
                actual: uri.clone(),
            }
        })?;
        let tree_root = module_tree_root(&canonical, &tree_dir)?;
        let path = tree_root.join(rel);
        if !path.is_file() {
            return Err(ReplayDeltaArchiveError::MissingRsyncOverlayFile {
                module_uri: canonical.clone(),
                path: path.display().to_string(),
            });
        }
        overlay_files.push((uri.clone(), path));
    }

    Ok(ReplayDeltaRsyncModule {
        module_uri: canonical,
        bucket_hash,
        bucket_dir,
        meta,
        overlay_only: entry.overlay_only,
        files,
        tree_dir,
        overlay_files,
    })
}

fn module_tree_root(module_uri: &str, tree_dir: &Path) -> Result<PathBuf, ReplayDeltaArchiveError> {
    let rest = module_uri.strip_prefix("rsync://").ok_or_else(|| {
        ReplayDeltaArchiveError::Base(ReplayArchiveError::InvalidRsyncUri {
            uri: module_uri.to_string(),
            detail: "URI must start with rsync://".to_string(),
        })
    })?;
    let mut parts = rest.trim_end_matches('/').split('/');
    let authority = parts.next().unwrap_or_default();
    let module = parts.next().unwrap_or_default();
    Ok(tree_dir.join(authority).join(module))
}

fn ensure_delta_version(entity: &'static str, version: u32) -> Result<(), ReplayDeltaArchiveError> {
    if version == 1 {
        Ok(())
    } else {
        Err(ReplayDeltaArchiveError::Base(
            ReplayArchiveError::UnsupportedVersion { entity, version },
        ))
    }
}

fn read_delta_json_file<T: for<'de> Deserialize<'de>>(
    path: &Path,
    entity: &'static str,
) -> Result<T, ReplayDeltaArchiveError> {
    let bytes = fs::read(path).map_err(|e| {
        ReplayDeltaArchiveError::Base(ReplayArchiveError::ReadFile {
            entity,
            path: path.display().to_string(),
            detail: e.to_string(),
        })
    })?;
    serde_json::from_slice(&bytes).map_err(|e| {
        ReplayDeltaArchiveError::Base(ReplayArchiveError::ParseJson {
            entity,
            path: path.display().to_string(),
            detail: e.to_string(),
        })
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn build_delta_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, String) {
        let temp = tempfile::tempdir().expect("tempdir");
        let archive_root = temp.path().join("payload-delta-archive");
        let capture = "delta-cap";
        let base_capture = "base-cap";
        let base_sha = "deadbeef";
        let capture_root = archive_root.join("v1").join("captures").join(capture);
        std::fs::create_dir_all(&capture_root).expect("mkdir capture root");
        std::fs::write(
            capture_root.join("capture.json"),
            format!(
                r#"{{"version":1,"captureId":"{capture}","createdAt":"2026-03-15T00:00:00Z","notes":""}}"#
            ),
        )
        .expect("write capture json");
        std::fs::write(
            capture_root.join("base.json"),
            format!(
                r#"{{"version":1,"baseCapture":"{base_capture}","baseLocksSha256":"{base_sha}","createdAt":"2026-03-15T00:00:00Z"}}"#
            ),
        )
        .expect("write base json");

        let notify_uri = "https://rrdp.example.test/notification.xml".to_string();
        let session = "11111111-1111-1111-1111-111111111111".to_string();
        let _target_serial = 12u64;
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let session_dir = capture_root
            .join("rrdp/repos")
            .join(&repo_hash)
            .join(&session);
        let deltas_dir = session_dir.join("deltas");
        std::fs::create_dir_all(&deltas_dir).expect("mkdir deltas");
        std::fs::write(
            session_dir.parent().unwrap().join("meta.json"),
            format!(
                r#"{{"version":1,"rpkiNotify":"{notify_uri}","createdAt":"2026-03-15T00:00:00Z","lastSeenAt":"2026-03-15T00:00:01Z"}}"#
            ),
        )
        .expect("write meta");
        std::fs::write(
            session_dir.parent().unwrap().join("transition.json"),
            format!(
                r#"{{"kind":"delta","base":{{"transport":"rrdp","session":"{session}","serial":10}},"target":{{"transport":"rrdp","session":"{session}","serial":12}},"delta_count":2,"deltas":[11,12]}}"#
            ),
        )
        .expect("write transition");
        std::fs::write(
            session_dir.join("notification-target-12.xml"),
            b"<notification/>",
        )
        .expect("write notification");
        std::fs::write(
            deltas_dir.join("delta-11-aaaa.xml"),
            b"<delta serial='11'/>",
        )
        .expect("write delta 11");
        std::fs::write(
            deltas_dir.join("delta-12-bbbb.xml"),
            b"<delta serial='12'/>",
        )
        .expect("write delta 12");
        std::fs::write(
            session_dir.parent().unwrap().join("target-archive-12.bin"),
            b"bin",
        )
        .expect("write target archive");

        let module_uri = "rsync://rsync.example.test/repo/".to_string();
        let module_hash = sha256_hex(module_uri.as_bytes());
        let module_bucket = capture_root.join("rsync/modules").join(&module_hash);
        let tree_root = module_bucket
            .join("tree")
            .join("rsync.example.test")
            .join("repo");
        std::fs::create_dir_all(tree_root.join("sub")).expect("mkdir tree root");
        std::fs::write(
            module_bucket.join("meta.json"),
            format!(
                r#"{{"version":1,"module":"{module_uri}","createdAt":"2026-03-15T00:00:00Z","lastSeenAt":"2026-03-15T00:00:01Z"}}"#
            ),
        )
        .expect("write rsync meta");
        std::fs::write(
            module_bucket.join("files.json"),
            format!(
                r#"{{"version":1,"module":"{module_uri}","fileCount":2,"files":["{module_uri}a.roa","{module_uri}sub/b.cer"]}}"#
            ),
        )
        .expect("write files json");
        std::fs::write(tree_root.join("a.roa"), b"roa").expect("write a.roa");
        std::fs::write(tree_root.join("sub").join("b.cer"), b"cer").expect("write b.cer");

        let locks_path = temp.path().join("locks-delta.json");
        std::fs::write(
            &locks_path,
            format!(
                r#"{{"version":1,"capture":"{capture}","baseCapture":"{base_capture}","baseLocksSha256":"{base_sha}","rrdp":{{"{notify_uri}":{{"kind":"delta","base":{{"transport":"rrdp","session":"{session}","serial":10}},"target":{{"transport":"rrdp","session":"{session}","serial":12}},"delta_count":2,"deltas":[11,12]}}}},"rsync":{{"{module_uri}":{{"file_count":2,"overlay_only":true}}}}}}"#
            ),
        )
        .expect("write locks-delta");
        (temp, archive_root, locks_path, notify_uri, module_uri)
    }

    #[test]
    fn delta_archive_index_loads_unchanged_and_fallback_rsync_rrdp_entries() {
        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture();
        let capture_root = archive_root.join("v1/captures/delta-cap");
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let repo_dir = capture_root.join("rrdp/repos").join(&repo_hash);
        std::fs::write(
            repo_dir.join("transition.json"),
            r#"{"kind":"unchanged","base":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"delta_count":0,"deltas":[]}"#,
        )
        .expect("rewrite transition unchanged");
        std::fs::write(
            &locks_path,
            r#"{"version":1,"capture":"delta-cap","baseCapture":"base-cap","baseLocksSha256":"deadbeef","rrdp":{"https://rrdp.example.test/notification.xml":{"kind":"unchanged","base":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"delta_count":0,"deltas":[]}},"rsync":{"rsync://rsync.example.test/repo/":{"file_count":2,"overlay_only":true}}}"#,
        ).expect("rewrite locks unchanged");
        let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path)
            .expect("load unchanged index");
        let repo = index.rrdp_repo(&notify_uri).expect("rrdp repo");
        assert_eq!(repo.transition.kind, ReplayDeltaRrdpKind::Unchanged);
        assert!(repo.target_notification_path.is_none());
        assert!(repo.delta_paths.is_empty());
        assert!(repo.target_archive_path.is_none());

        std::fs::write(
            repo_dir.join("transition.json"),
            r#"{"kind":"fallback-rsync","base":{"transport":"rsync","session":null,"serial":null},"target":{"transport":"rsync","session":null,"serial":null},"delta_count":0,"deltas":[]}"#,
        ).expect("rewrite transition fallback-rsync");
        std::fs::write(
            &locks_path,
            r#"{"version":1,"capture":"delta-cap","baseCapture":"base-cap","baseLocksSha256":"deadbeef","rrdp":{"https://rrdp.example.test/notification.xml":{"kind":"fallback-rsync","base":{"transport":"rsync","session":null,"serial":null},"target":{"transport":"rsync","session":null,"serial":null},"delta_count":0,"deltas":[]}},"rsync":{"rsync://rsync.example.test/repo/":{"file_count":2,"overlay_only":true}}}"#,
        ).expect("rewrite locks fallback-rsync");
        let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path)
            .expect("load fallback-rsync index");
        let repo = index.rrdp_repo(&notify_uri).expect("rrdp repo");
        assert_eq!(repo.transition.kind, ReplayDeltaRrdpKind::FallbackRsync);
        assert!(repo.target_notification_path.is_none());
        assert!(repo.delta_paths.is_empty());
    }

    #[test]
    fn delta_archive_index_resolves_rsync_module_from_base_uri() {
        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture();
        let index =
            ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).expect("load delta index");
        let module = index
            .resolve_rsync_module_for_base_uri("rsync://rsync.example.test/repo/sub/path")
            .expect("resolve module");
        assert_eq!(module.module_uri, "rsync://rsync.example.test/repo/");
    }

    #[test]
    fn delta_archive_index_rejects_unsupported_versions_and_meta_mismatches() {
        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture();
        std::fs::write(
            &locks_path,
            r#"{"version":2,"capture":"delta-cap","baseCapture":"base-cap","baseLocksSha256":"deadbeef","rrdp":{},"rsync":{}}"#,
        ).expect("rewrite locks version");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(
                err,
                ReplayDeltaArchiveError::Base(ReplayArchiveError::UnsupportedVersion {
                    entity: "payload delta locks",
                    version: 2
                })
            ),
            "{err}"
        );

        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        std::fs::write(
            archive_root.join("v1/captures/delta-cap/rrdp/repos").join(&repo_hash).join("meta.json"),
            r#"{"version":1,"rpkiNotify":"https://other.example/notification.xml","createdAt":"2026-03-15T00:00:00Z","lastSeenAt":"2026-03-15T00:00:01Z"}"#,
        ).expect("rewrite rrdp meta");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::RrdpMetaMismatch { .. }),
            "{err}"
        );

        let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_delta_fixture();
        let module_hash = sha256_hex(module_uri.as_bytes());
        std::fs::write(
            archive_root.join("v1/captures/delta-cap/rsync/modules").join(&module_hash).join("meta.json"),
            r#"{"version":1,"module":"rsync://other.example/repo/","createdAt":"2026-03-15T00:00:00Z","lastSeenAt":"2026-03-15T00:00:01Z"}"#,
        ).expect("rewrite rsync meta");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::RsyncMetaMismatch { .. }),
            "{err}"
        );
    }

    #[test]
    fn delta_archive_index_rejects_transition_base_target_and_serial_mismatches() {
        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let repo_dir = archive_root
            .join("v1/captures/delta-cap/rrdp/repos")
            .join(&repo_hash);

        std::fs::write(
            repo_dir.join("transition.json"),
            r#"{"kind":"delta","base":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":9},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":12},"delta_count":2,"deltas":[11,12]}"#,
        ).expect("rewrite transition base");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::TransitionBaseMismatch { .. }),
            "{err}"
        );

        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let repo_dir = archive_root
            .join("v1/captures/delta-cap/rrdp/repos")
            .join(&repo_hash);
        std::fs::write(
            repo_dir.join("transition.json"),
            r#"{"kind":"delta","base":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":13},"delta_count":2,"deltas":[11,12]}"#,
        ).expect("rewrite transition target");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(
                err,
                ReplayDeltaArchiveError::TransitionTargetMismatch { .. }
            ),
            "{err}"
        );

        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let repo_dir = archive_root
            .join("v1/captures/delta-cap/rrdp/repos")
            .join(&repo_hash);
        std::fs::write(
            repo_dir.join("transition.json"),
            r#"{"kind":"delta","base":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":12},"delta_count":1,"deltas":[12]}"#,
        ).expect("rewrite transition deltas");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::DeltaSerialListMismatch { .. }),
            "{err}"
        );
    }

    #[test]
    fn delta_archive_index_rejects_missing_session_dir_and_overlay_files() {
        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let session_dir = archive_root
            .join("v1/captures/delta-cap/rrdp/repos")
            .join(&repo_hash)
            .join("11111111-1111-1111-1111-111111111111");
        std::fs::remove_dir_all(&session_dir).expect("remove session dir");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::MissingDeltaSessionDir { .. }),
            "{err}"
        );

        let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_delta_fixture();
        let module_hash = sha256_hex(module_uri.as_bytes());
        let overlay_path = archive_root
            .join("v1/captures/delta-cap/rsync/modules")
            .join(&module_hash)
            .join("tree/rsync.example.test/repo/sub/b.cer");
        std::fs::remove_file(overlay_path).expect("remove overlay file");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::MissingRsyncOverlayFile { .. }),
            "{err}"
        );
    }

    #[test]
    fn delta_archive_index_accepts_missing_rsync_module_meta_when_files_and_tree_exist() {
        let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_delta_fixture();
        let module_hash = sha256_hex(module_uri.as_bytes());
        let meta_path = archive_root
            .join("v1/captures/delta-cap/rsync/modules")
            .join(module_hash)
            .join("meta.json");
        std::fs::remove_file(&meta_path).expect("remove delta rsync module meta");

        let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path)
            .expect("load delta replay index without rsync meta");
        let module = index
            .rsync_modules
            .get(&module_uri)
            .expect("module present");
        assert_eq!(module.meta.module, module_uri);
        assert_eq!(module.meta.version, 1);
    }

    #[test]
    fn delta_archive_index_accepts_correct_base_locks_sha_and_rejects_missing_module_resolution() {
        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture();
        let index =
            ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).expect("load delta index");
        let locks_bytes = std::fs::read(&locks_path).expect("read locks bytes");
        assert!(
            index
                .validate_base_locks_sha256_bytes(&locks_bytes)
                .is_err()
        );
        let err = index
            .resolve_rsync_module_for_base_uri("rsync://missing.example/repo/path")
            .unwrap_err();
        assert!(
            matches!(
                err,
                ReplayDeltaArchiveError::MissingRsyncModuleBucket { .. }
            ),
            "{err}"
        );
    }

    #[test]
    fn delta_archive_index_loads_session_reset_and_gap_entries_without_target_files() {
        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let repo_dir = archive_root
            .join("v1/captures/delta-cap/rrdp/repos")
            .join(&repo_hash);

        for kind in ["session-reset", "gap"] {
            std::fs::write(
                repo_dir.join("transition.json"),
                format!(
                    r#"{{"kind":"{kind}","base":{{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10}},"target":{{"transport":"rrdp","session":"22222222-2222-2222-2222-222222222222","serial":12}},"delta_count":0,"deltas":[]}}"#,
                ),
            )
            .expect("rewrite transition kind");
            std::fs::write(
                &locks_path,
                format!(
                    r#"{{"version":1,"capture":"delta-cap","baseCapture":"base-cap","baseLocksSha256":"deadbeef","rrdp":{{"{notify_uri}":{{"kind":"{kind}","base":{{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10}},"target":{{"transport":"rrdp","session":"22222222-2222-2222-2222-222222222222","serial":12}},"delta_count":0,"deltas":[]}}}},"rsync":{{"rsync://rsync.example.test/repo/":{{"file_count":2,"overlay_only":true}}}}}}"#,
                ),
            )
            .expect("rewrite locks kind");
            let index = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path)
                .expect("load delta index");
            let repo = index.rrdp_repo(&notify_uri).expect("rrdp repo");
            assert!(repo.target_notification_path.is_none());
            assert!(repo.delta_paths.is_empty());
        }
    }
    #[test]
    fn delta_archive_index_loads_rrdp_and_rsync_entries() {
        let (_temp, archive_root, locks_path, notify_uri, module_uri) = build_delta_fixture();
        let index =
            ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).expect("load delta index");
        assert_eq!(index.capture_meta.capture_id, "delta-cap");
        assert_eq!(index.base_meta.base_capture, "base-cap");
        assert_eq!(index.rrdp_repos.len(), 1);
        assert_eq!(index.rsync_modules.len(), 1);

        let repo = index.rrdp_repo(&notify_uri).expect("rrdp repo");
        assert_eq!(repo.transition.kind, ReplayDeltaRrdpKind::Delta);
        assert_eq!(repo.transition.delta_count, 2);
        assert_eq!(repo.delta_paths.len(), 2);
        assert!(repo.target_notification_path.as_ref().unwrap().is_file());
        assert!(repo.target_archive_path.as_ref().unwrap().is_file());

        let module = index.rsync_module(&module_uri).expect("rsync module");
        assert_eq!(module.files.file_count, 2);
        assert_eq!(module.overlay_files.len(), 2);
        assert!(module.overlay_files.iter().all(|(_, path)| path.is_file()));
    }

    #[test]
    fn delta_archive_index_rejects_capture_and_sha_mismatches() {
        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture();
        let capture_root = archive_root.join("v1/captures/delta-cap");
        std::fs::write(
            capture_root.join("capture.json"),
            r#"{"version":1,"captureId":"other-cap","createdAt":"2026-03-15T00:00:00Z","notes":""}"#,
        )
        .expect("rewrite capture json");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::CaptureIdMismatch { .. }),
            "{err}"
        );

        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture();
        let capture_root = archive_root.join("v1/captures/delta-cap");
        std::fs::write(
            capture_root.join("base.json"),
            r#"{"version":1,"baseCapture":"base-cap","baseLocksSha256":"beefdead","createdAt":"2026-03-15T00:00:00Z"}"#,
        )
        .expect("rewrite base json sha");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::BaseLocksShaMismatch { .. }),
            "{err}"
        );
    }

    #[test]
    fn delta_archive_index_rejects_missing_target_notification_and_repo_bucket() {
        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let session_dir = archive_root
            .join("v1/captures/delta-cap/rrdp/repos")
            .join(&repo_hash)
            .join("11111111-1111-1111-1111-111111111111");
        std::fs::remove_file(session_dir.join("notification-target-12.xml"))
            .expect("remove target notification");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(
                err,
                ReplayDeltaArchiveError::MissingTargetNotification { .. }
            ),
            "{err}"
        );

        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let repo_dir = archive_root
            .join("v1/captures/delta-cap/rrdp/repos")
            .join(&repo_hash);
        std::fs::remove_dir_all(repo_dir).expect("remove repo dir");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::MissingDeltaRepoBucket { .. }),
            "{err}"
        );
    }
    #[test]
    fn delta_archive_index_rejects_base_meta_mismatch() {
        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture();
        let capture_root = archive_root.join("v1/captures/delta-cap");
        std::fs::write(
            capture_root.join("base.json"),
            r#"{"version":1,"baseCapture":"other","baseLocksSha256":"deadbeef","createdAt":"2026-03-15T00:00:00Z"}"#,
        )
        .expect("rewrite base json");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::BaseCaptureMismatch { .. }),
            "{err}"
        );
    }

    #[test]
    fn delta_archive_index_rejects_transition_mismatch_and_missing_delta_file() {
        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let repo_dir = archive_root
            .join("v1/captures/delta-cap/rrdp/repos")
            .join(&repo_hash);
        std::fs::write(
            repo_dir.join("transition.json"),
            r#"{"kind":"unchanged","base":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":10},"target":{"transport":"rrdp","session":"11111111-1111-1111-1111-111111111111","serial":12},"delta_count":2,"deltas":[11,12]}"#,
        )
        .expect("rewrite transition");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::TransitionKindMismatch { .. }),
            "{err}"
        );

        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_delta_fixture();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let delta_path = archive_root
            .join("v1/captures/delta-cap/rrdp/repos")
            .join(&repo_hash)
            .join("11111111-1111-1111-1111-111111111111/deltas/delta-12-bbbb.xml");
        std::fs::remove_file(delta_path).expect("remove delta");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::MissingDeltaFile { .. }),
            "{err}"
        );
    }

    #[test]
    fn delta_archive_index_validates_base_locks_sha256_bytes_and_file() {
        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_delta_fixture();
        let index =
            ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).expect("load delta index");
        let err = index
            .validate_base_locks_sha256_bytes(b"not-the-right-base-locks")
            .unwrap_err();
        assert!(
            matches!(
                err,
                ReplayDeltaArchiveError::BaseLocksBytesShaMismatch { .. }
            ),
            "{err}"
        );

        let temp_file = tempfile::NamedTempFile::new().expect("tempfile");
        std::fs::write(temp_file.path(), b"still-wrong").expect("write base locks file");
        let err = index
            .validate_base_locks_sha256_file(temp_file.path())
            .unwrap_err();
        assert!(
            matches!(
                err,
                ReplayDeltaArchiveError::BaseLocksBytesShaMismatch { .. }
            ),
            "{err}"
        );
    }

    #[test]
    fn delta_archive_index_rejects_rsync_files_mismatch() {
        let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_delta_fixture();
        let module_hash = sha256_hex(module_uri.as_bytes());
        let module_dir = archive_root
            .join("v1/captures/delta-cap/rsync/modules")
            .join(&module_hash);
        std::fs::write(
            module_dir.join("files.json"),
            format!(
                r#"{{"version":1,"module":"{module_uri}","fileCount":3,"files":["{module_uri}a.roa"]}}"#
            ),
        )
        .expect("rewrite files json");
        let err = ReplayDeltaArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayDeltaArchiveError::RsyncFileCountMismatch { .. }),
            "{err}"
        );
    }
}
