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

use serde::Deserialize;
use sha2::Digest;

#[derive(Debug, thiserror::Error)]
pub enum ReplayArchiveError {
    #[error("read {entity} failed: {path}: {detail}")]
    ReadFile {
        entity: &'static str,
        path: String,
        detail: String,
    },

    #[error("parse {entity} JSON failed: {path}: {detail}")]
    ParseJson {
        entity: &'static str,
        path: String,
        detail: String,
    },

    #[error("unsupported {entity} version: expected 1, got {version}")]
    UnsupportedVersion { entity: &'static str, version: u32 },

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

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

    #[error("RRDP lock entry invalid for {notify_uri}: {detail}")]
    InvalidRrdpLock { notify_uri: String, detail: String },

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

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

    #[error("RRDP session directory not found for {notify_uri}: {path}")]
    MissingRrdpSessionDir { notify_uri: String, path: String },

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

    #[error("locked snapshot file not found for {notify_uri} at serial {serial} in {session_dir}")]
    MissingLockedSnapshot {
        notify_uri: String,
        serial: u64,
        session_dir: String,
    },

    #[error(
        "multiple locked snapshot files found for {notify_uri} at serial {serial} in {session_dir}"
    )]
    AmbiguousLockedSnapshot {
        notify_uri: String,
        serial: u64,
        session_dir: String,
    },

    #[error("rsync URI is not a valid module/base URI: {uri}: {detail}")]
    InvalidRsyncUri { uri: String, detail: String },

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

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

    #[error("rsync module tree not found for {module_uri}: {path}")]
    MissingRsyncTree { module_uri: String, path: String },

    #[error("no replay lock found for RRDP notification URI: {0}")]
    MissingRrdpLock(String),

    #[error("no replay lock found for rsync module: {0}")]
    MissingRsyncLock(String),
}

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

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

#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct ReplayRsyncLock {
    pub transport: ReplayTransport,
}

#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct ReplayLocks {
    pub version: u32,
    pub capture: String,
    pub rrdp: BTreeMap<String, ReplayRrdpLock>,
    pub rsync: BTreeMap<String, ReplayRsyncLock>,
}

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

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

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

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReplayRrdpRepo {
    pub notify_uri: String,
    pub bucket_hash: String,
    pub bucket_dir: PathBuf,
    pub meta: ReplayRrdpRepoMeta,
    pub locked_session: String,
    pub locked_serial: u64,
    pub session_dir: PathBuf,
    pub locked_notification_path: PathBuf,
    pub locked_snapshot_path: PathBuf,
    pub available_delta_paths: Vec<PathBuf>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReplayRsyncModule {
    pub module_uri: String,
    pub bucket_hash: String,
    pub bucket_dir: PathBuf,
    pub meta: ReplayRsyncModuleMeta,
    pub tree_dir: PathBuf,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReplayArchiveIndex {
    pub archive_root: PathBuf,
    pub capture_root: PathBuf,
    pub locks_path: PathBuf,
    pub locks: ReplayLocks,
    pub capture_meta: ReplayCaptureMeta,
    pub rrdp_repos: BTreeMap<String, ReplayRrdpRepo>,
    pub rsync_modules: BTreeMap<String, ReplayRsyncModule>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ReplayArchiveLoadMode {
    Strict,
    AllowMissingRsyncModules,
}

impl ReplayArchiveIndex {
    pub fn load(
        archive_root: impl AsRef<Path>,
        locks_path: impl AsRef<Path>,
    ) -> Result<Self, ReplayArchiveError> {
        Self::load_with_mode(archive_root, locks_path, ReplayArchiveLoadMode::Strict)
    }

    pub fn load_allow_missing_rsync_modules(
        archive_root: impl AsRef<Path>,
        locks_path: impl AsRef<Path>,
    ) -> Result<Self, ReplayArchiveError> {
        Self::load_with_mode(
            archive_root,
            locks_path,
            ReplayArchiveLoadMode::AllowMissingRsyncModules,
        )
    }

    fn load_with_mode(
        archive_root: impl AsRef<Path>,
        locks_path: impl AsRef<Path>,
        load_mode: ReplayArchiveLoadMode,
    ) -> Result<Self, ReplayArchiveError> {
        let archive_root = archive_root.as_ref().to_path_buf();
        let locks_path = locks_path.as_ref().to_path_buf();

        let locks: ReplayLocks = read_json_file(&locks_path, "payload replay locks")?;
        ensure_version("payload replay locks", locks.version)?;

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

        let capture_meta_path = capture_root.join("capture.json");
        let capture_meta: ReplayCaptureMeta = read_json_file(&capture_meta_path, "capture meta")?;
        ensure_version("capture meta", capture_meta.version)?;
        if capture_meta.capture_id != locks.capture {
            return Err(ReplayArchiveError::CaptureIdMismatch {
                locks_capture: locks.capture.clone(),
                capture_json: capture_meta.capture_id.clone(),
            });
        }

        let mut rrdp_repos = BTreeMap::new();
        for (notify_uri, lock) in &locks.rrdp {
            match lock.transport {
                ReplayTransport::Rrdp => {
                    let repo = load_rrdp_repo(&capture_root, notify_uri, lock)?;
                    rrdp_repos.insert(notify_uri.clone(), repo);
                }
                ReplayTransport::Rsync => {
                    validate_rsync_transport_lock(notify_uri, lock)?;
                }
            }
        }

        let mut rsync_modules = BTreeMap::new();
        for (module_uri, lock) in &locks.rsync {
            if lock.transport != ReplayTransport::Rsync {
                return Err(ReplayArchiveError::InvalidRrdpLock {
                    notify_uri: module_uri.clone(),
                    detail: "rsync lock transport must be rsync".to_string(),
                });
            }
            match load_rsync_module(&capture_root, module_uri) {
                Ok(module) => {
                    rsync_modules.insert(module.module_uri.clone(), module);
                }
                Err(ReplayArchiveError::MissingRsyncModuleBucket { .. })
                    if load_mode == ReplayArchiveLoadMode::AllowMissingRsyncModules => {}
                Err(err) => return Err(err),
            }
        }

        Ok(Self {
            archive_root,
            capture_root,
            locks_path,
            locks,
            capture_meta,
            rrdp_repos,
            rsync_modules,
        })
    }

    pub fn rrdp_lock(&self, notify_uri: &str) -> Option<&ReplayRrdpLock> {
        self.locks.rrdp.get(notify_uri)
    }

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

    pub fn require_rrdp_repo(
        &self,
        notify_uri: &str,
    ) -> Result<&ReplayRrdpRepo, ReplayArchiveError> {
        self.rrdp_repos
            .get(notify_uri)
            .ok_or_else(|| ReplayArchiveError::MissingRrdpLock(notify_uri.to_string()))
    }

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

    pub fn resolve_rsync_module_for_base_uri(
        &self,
        rsync_base_uri: &str,
    ) -> Result<&ReplayRsyncModule, ReplayArchiveError> {
        let module_uri = canonical_rsync_module(rsync_base_uri)?;
        self.rsync_modules
            .get(&module_uri)
            .ok_or(ReplayArchiveError::MissingRsyncLock(module_uri))
    }
}

fn load_rrdp_repo(
    capture_root: &Path,
    notify_uri: &str,
    lock: &ReplayRrdpLock,
) -> Result<ReplayRrdpRepo, ReplayArchiveError> {
    let session = lock
        .session
        .as_deref()
        .ok_or_else(|| ReplayArchiveError::InvalidRrdpLock {
            notify_uri: notify_uri.to_string(),
            detail: "transport=rrdp requires non-null session".to_string(),
        })?;
    let serial = lock
        .serial
        .ok_or_else(|| ReplayArchiveError::InvalidRrdpLock {
            notify_uri: notify_uri.to_string(),
            detail: "transport=rrdp requires non-null serial".to_string(),
        })?;

    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(ReplayArchiveError::MissingRrdpRepoBucket {
            notify_uri: notify_uri.to_string(),
            path: bucket_dir.display().to_string(),
        });
    }

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

    let session_dir = bucket_dir.join(session);
    if !session_dir.is_dir() {
        return Err(ReplayArchiveError::MissingRrdpSessionDir {
            notify_uri: notify_uri.to_string(),
            path: session_dir.display().to_string(),
        });
    }

    let locked_notification_path = session_dir.join(format!("notification-{serial}.xml"));
    if !locked_notification_path.is_file() {
        return Err(ReplayArchiveError::MissingLockedNotification {
            notify_uri: notify_uri.to_string(),
            path: locked_notification_path.display().to_string(),
        });
    }

    let mut snapshot_candidates: Vec<PathBuf> = fs::read_dir(&session_dir)
        .map_err(|e| ReplayArchiveError::ReadFile {
            entity: "RRDP session directory",
            path: session_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(|name| name.to_str())
                .is_some_and(|name| {
                    name.starts_with(&format!("snapshot-{serial}-")) && name.ends_with(".xml")
                })
        })
        .collect();
    snapshot_candidates.sort();
    let locked_snapshot_path = match snapshot_candidates.len() {
        0 => {
            return Err(ReplayArchiveError::MissingLockedSnapshot {
                notify_uri: notify_uri.to_string(),
                serial,
                session_dir: session_dir.display().to_string(),
            });
        }
        1 => snapshot_candidates.remove(0),
        _ => {
            return Err(ReplayArchiveError::AmbiguousLockedSnapshot {
                notify_uri: notify_uri.to_string(),
                serial,
                session_dir: session_dir.display().to_string(),
            });
        }
    };

    let mut available_delta_paths: Vec<PathBuf> = fs::read_dir(&session_dir)
        .map_err(|e| ReplayArchiveError::ReadFile {
            entity: "RRDP session directory",
            path: session_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(|name| name.to_str())
                .is_some_and(|name| name.starts_with("delta-") && name.ends_with(".xml"))
        })
        .collect();
    available_delta_paths.sort();

    Ok(ReplayRrdpRepo {
        notify_uri: notify_uri.to_string(),
        bucket_hash,
        bucket_dir,
        meta,
        locked_session: session.to_string(),
        locked_serial: serial,
        session_dir,
        locked_notification_path,
        locked_snapshot_path,
        available_delta_paths,
    })
}

fn validate_rsync_transport_lock(
    notify_uri: &str,
    lock: &ReplayRrdpLock,
) -> Result<(), ReplayArchiveError> {
    if lock.session.is_some() || lock.serial.is_some() {
        return Err(ReplayArchiveError::InvalidRrdpLock {
            notify_uri: notify_uri.to_string(),
            detail: "transport=rsync requires null session and serial".to_string(),
        });
    }
    Ok(())
}

fn load_rsync_module(
    capture_root: &Path,
    module_uri: &str,
) -> Result<ReplayRsyncModule, ReplayArchiveError> {
    let canonical_module = canonical_rsync_module(module_uri)?;
    let bucket_hash = sha256_hex(canonical_module.as_bytes());
    let bucket_dir = capture_root
        .join("rsync")
        .join("modules")
        .join(&bucket_hash);
    if !bucket_dir.is_dir() {
        return Err(ReplayArchiveError::MissingRsyncModuleBucket {
            module_uri: canonical_module.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_json_file(&meta_path, "rsync module meta")?;
        ensure_version("rsync module meta", meta.version)?;
        if meta.module != canonical_module {
            return Err(ReplayArchiveError::RsyncMetaMismatch {
                expected: canonical_module.clone(),
                actual: meta.module.clone(),
            });
        }
        meta
    } else {
        ReplayRsyncModuleMeta {
            version: 1,
            module: canonical_module.clone(),
            created_at: String::new(),
            last_seen_at: String::new(),
        }
    };

    let tree_dir = bucket_dir.join("tree");
    if !tree_dir.is_dir() {
        return Err(ReplayArchiveError::MissingRsyncTree {
            module_uri: canonical_module.clone(),
            path: tree_dir.display().to_string(),
        });
    }

    Ok(ReplayRsyncModule {
        module_uri: canonical_module,
        bucket_hash,
        bucket_dir,
        meta,
        tree_dir,
    })
}

pub fn canonical_rsync_module(rsync_uri: &str) -> Result<String, ReplayArchiveError> {
    let raw = rsync_uri.trim();
    let prefix = "rsync://";
    let rest = raw
        .strip_prefix(prefix)
        .ok_or_else(|| ReplayArchiveError::InvalidRsyncUri {
            uri: rsync_uri.to_string(),
            detail: "URI must start with rsync://".to_string(),
        })?;

    let mut parts = rest.split('/');
    let authority = parts.next().unwrap_or_default();
    let module = parts.next().unwrap_or_default();
    if authority.is_empty() || module.is_empty() {
        return Err(ReplayArchiveError::InvalidRsyncUri {
            uri: rsync_uri.to_string(),
            detail: "URI must contain authority and module".to_string(),
        });
    }

    Ok(format!("rsync://{authority}/{module}/"))
}

pub fn sha256_hex(bytes: &[u8]) -> String {
    let digest = sha2::Sha256::digest(bytes);
    hex::encode(digest)
}

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

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

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

    fn build_minimal_archive() -> (tempfile::TempDir, PathBuf, PathBuf, String, String) {
        let temp = tempfile::tempdir().expect("tempdir");
        let archive_root = temp.path().join("payload-archive");
        let capture = "capture-001";
        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-13T00:00:00Z","notes":""}}"#
            ),
        )
        .expect("write capture json");

        let notify_uri = "https://rrdp.example.test/notification.xml".to_string();
        let session = "11111111-1111-1111-1111-111111111111".to_string();
        let serial = 42u64;
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let repo_dir = capture_root.join("rrdp").join("repos").join(&repo_hash);
        let session_dir = repo_dir.join(&session);
        std::fs::create_dir_all(&session_dir).expect("mkdir session dir");
        std::fs::write(
            repo_dir.join("meta.json"),
            format!(
                r#"{{"version":1,"rpkiNotify":"{notify_uri}","createdAt":"2026-03-13T00:00:00Z","lastSeenAt":"2026-03-13T00:00:01Z"}}"#
            ),
        )
        .expect("write repo meta");
        std::fs::write(session_dir.join("notification-42.xml"), b"<notification/>")
            .expect("write notification");
        std::fs::write(session_dir.join("snapshot-42-deadbeef.xml"), b"<snapshot/>")
            .expect("write snapshot");

        let module_uri = "rsync://rsync.example.test/repo/".to_string();
        let mod_hash = sha256_hex(module_uri.as_bytes());
        let mod_dir = capture_root.join("rsync").join("modules").join(&mod_hash);
        let tree_dir = mod_dir.join("tree").join("rsync.example.test").join("repo");
        std::fs::create_dir_all(&tree_dir).expect("mkdir tree dir");
        std::fs::write(
            mod_dir.join("meta.json"),
            format!(
                r#"{{"version":1,"module":"{module_uri}","createdAt":"2026-03-13T00:00:00Z","lastSeenAt":"2026-03-13T00:00:01Z"}}"#
            ),
        )
        .expect("write rsync meta");
        std::fs::write(tree_dir.join("a.roa"), b"roa").expect("write rsync object");

        let locks_path = temp.path().join("locks.json");
        std::fs::write(
            &locks_path,
            format!(
                r#"{{
  "version":1,
  "capture":"{capture}",
  "rrdp":{{
    "{notify_uri}":{{"transport":"rrdp","session":"{session}","serial":{serial}}},
    "https://rrdp-fallback.example.test/notification.xml":{{"transport":"rsync","session":null,"serial":null}}
  }},
  "rsync":{{
    "{module_uri}":{{"transport":"rsync"}}
  }}
}}"#
            ),
        )
        .expect("write locks");

        (temp, archive_root, locks_path, notify_uri, module_uri)
    }

    #[test]
    fn canonical_rsync_module_normalizes_subpaths_and_trailing_slash() {
        let normalized = canonical_rsync_module("rsync://example.test/repo/sub/dir/file.roa")
            .expect("normalize module");
        assert_eq!(normalized, "rsync://example.test/repo/");

        let normalized = canonical_rsync_module("rsync://example.test/repo")
            .expect("normalize module without slash");
        assert_eq!(normalized, "rsync://example.test/repo/");
    }

    #[test]
    fn replay_archive_index_loads_rrdp_and_rsync_entries() {
        let (_temp, archive_root, locks_path, notify_uri, module_uri) = build_minimal_archive();
        let index =
            ReplayArchiveIndex::load(&archive_root, &locks_path).expect("load replay index");

        assert_eq!(index.capture_meta.capture_id, "capture-001");
        assert_eq!(index.rrdp_repos.len(), 1);
        assert_eq!(index.locks.rrdp.len(), 2);
        assert_eq!(index.rsync_modules.len(), 1);

        let repo = index.require_rrdp_repo(&notify_uri).expect("rrdp repo");
        assert_eq!(repo.locked_serial, 42);
        assert!(
            repo.locked_notification_path
                .ends_with("notification-42.xml")
        );
        assert!(
            repo.locked_snapshot_path
                .ends_with("snapshot-42-deadbeef.xml")
        );
        assert!(repo.available_delta_paths.is_empty());

        let module = index
            .resolve_rsync_module_for_base_uri("rsync://rsync.example.test/repo/sub/path")
            .expect("resolve module from base");
        assert_eq!(module.module_uri, module_uri);
        assert!(
            module
                .tree_dir
                .join("rsync.example.test")
                .join("repo")
                .join("a.roa")
                .is_file()
        );
    }

    #[test]
    fn replay_archive_index_rejects_rsync_transport_with_session_or_serial() {
        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_minimal_archive();
        let bad = locks_path.with_file_name("bad-locks.json");
        std::fs::write(
            &bad,
            r#"{
  "version":1,
  "capture":"capture-001",
  "rrdp":{
    "https://rrdp-fallback.example.test/notification.xml":{"transport":"rsync","session":"oops","serial":1}
  },
  "rsync":{}
}"#,
        )
        .expect("write bad locks");

        let err = ReplayArchiveIndex::load(&archive_root, &bad).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::InvalidRrdpLock { .. }),
            "{err}"
        );
    }

    #[test]
    fn replay_archive_index_rejects_mismatched_rrdp_meta() {
        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_minimal_archive();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let meta_path = archive_root
            .join("v1/captures/capture-001/rrdp/repos")
            .join(repo_hash)
            .join("meta.json");
        std::fs::write(
            &meta_path,
            r#"{"version":1,"rpkiNotify":"https://other.example/notification.xml","createdAt":"2026-03-13T00:00:00Z","lastSeenAt":"2026-03-13T00:00:01Z"}"#,
        )
        .expect("rewrite meta");

        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::RrdpMetaMismatch { .. }),
            "{err}"
        );
    }

    #[test]
    fn replay_archive_index_rejects_missing_snapshot() {
        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_minimal_archive();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let session_dir = archive_root
            .join("v1/captures/capture-001/rrdp/repos")
            .join(repo_hash)
            .join("11111111-1111-1111-1111-111111111111");
        std::fs::remove_file(session_dir.join("snapshot-42-deadbeef.xml"))
            .expect("remove snapshot");

        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::MissingLockedSnapshot { .. }),
            "{err}"
        );
    }

    #[test]
    fn replay_archive_index_rejects_ambiguous_snapshot_candidates() {
        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_minimal_archive();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let session_dir = archive_root
            .join("v1/captures/capture-001/rrdp/repos")
            .join(repo_hash)
            .join("11111111-1111-1111-1111-111111111111");
        std::fs::write(session_dir.join("snapshot-42-another.xml"), b"<snapshot/>")
            .expect("write second snapshot");

        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::AmbiguousLockedSnapshot { .. }),
            "{err}"
        );
    }

    #[test]
    fn canonical_rsync_module_rejects_invalid_uris() {
        let err = canonical_rsync_module("https://example.test/repo/").unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::InvalidRsyncUri { .. }),
            "{err}"
        );

        let err = canonical_rsync_module("rsync://example.test").unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::InvalidRsyncUri { .. }),
            "{err}"
        );
    }

    #[test]
    fn replay_archive_index_rejects_missing_capture_directory() {
        let temp = tempfile::tempdir().expect("tempdir");
        let archive_root = temp.path().join("payload-archive");
        std::fs::create_dir_all(&archive_root).expect("mkdir archive root");
        let locks_path = temp.path().join("locks.json");
        std::fs::write(
            &locks_path,
            r#"{"version":1,"capture":"missing","rrdp":{},"rsync":{}}"#,
        )
        .expect("write locks");

        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::MissingCaptureDirectory(_)),
            "{err}"
        );
    }

    #[test]
    fn replay_archive_index_rejects_missing_rrdp_bucket_and_session_and_notification() {
        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_minimal_archive();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let repo_dir = archive_root
            .join("v1/captures/capture-001/rrdp/repos")
            .join(&repo_hash);

        std::fs::remove_dir_all(&repo_dir).expect("remove repo dir");
        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::MissingRrdpRepoBucket { .. }),
            "{err}"
        );

        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_minimal_archive();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let session_dir = archive_root
            .join("v1/captures/capture-001/rrdp/repos")
            .join(&repo_hash)
            .join("11111111-1111-1111-1111-111111111111");
        std::fs::remove_dir_all(&session_dir).expect("remove session dir");
        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::MissingRrdpSessionDir { .. }),
            "{err}"
        );

        let (_temp, archive_root, locks_path, notify_uri, _module_uri) = build_minimal_archive();
        let repo_hash = sha256_hex(notify_uri.as_bytes());
        let session_dir = archive_root
            .join("v1/captures/capture-001/rrdp/repos")
            .join(&repo_hash)
            .join("11111111-1111-1111-1111-111111111111");
        std::fs::remove_file(session_dir.join("notification-42.xml")).expect("remove notification");
        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::MissingLockedNotification { .. }),
            "{err}"
        );
    }

    #[test]
    fn replay_archive_index_rejects_invalid_json_and_missing_rsync_bucket() {
        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_minimal_archive();
        std::fs::write(&locks_path, b"not json").expect("corrupt locks");
        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(
                err,
                ReplayArchiveError::ParseJson {
                    entity: "payload replay locks",
                    ..
                }
            ),
            "{err}"
        );

        let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_minimal_archive();
        let mod_hash = sha256_hex(module_uri.as_bytes());
        let mod_dir = archive_root
            .join("v1/captures/capture-001/rsync/modules")
            .join(&mod_hash);
        std::fs::remove_dir_all(&mod_dir).expect("remove module dir");
        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::MissingRsyncModuleBucket { .. }),
            "{err}"
        );
    }

    #[test]
    fn replay_archive_index_can_skip_missing_rsync_modules_in_lenient_mode() {
        let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_minimal_archive();
        let mod_hash = sha256_hex(module_uri.as_bytes());
        let mod_dir = archive_root
            .join("v1/captures/capture-001/rsync/modules")
            .join(mod_hash);
        std::fs::remove_dir_all(&mod_dir).expect("remove module dir");

        let index =
            ReplayArchiveIndex::load_allow_missing_rsync_modules(&archive_root, &locks_path)
                .expect("load lenient replay index");
        assert!(index.rsync_modules.is_empty());
    }
    #[test]
    fn replay_archive_index_accepts_missing_rsync_module_meta_when_tree_exists() {
        let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_minimal_archive();
        let mod_hash = sha256_hex(module_uri.as_bytes());
        let meta_path = archive_root
            .join("v1/captures/capture-001/rsync/modules")
            .join(mod_hash)
            .join("meta.json");
        std::fs::remove_file(&meta_path).expect("remove rsync module meta");

        let index =
            ReplayArchiveIndex::load_allow_missing_rsync_modules(&archive_root, &locks_path)
                .expect("load 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 replay_archive_index_rejects_capture_id_mismatch() {
        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_minimal_archive();
        let capture_json = archive_root.join("v1/captures/capture-001/capture.json");
        std::fs::write(
            &capture_json,
            r#"{"version":1,"captureId":"other-capture","createdAt":"2026-03-13T00:00:00Z","notes":""}"#,
        )
        .expect("rewrite capture json");

        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::CaptureIdMismatch { .. }),
            "{err}"
        );
    }

    #[test]
    fn replay_archive_index_rejects_unsupported_locks_version() {
        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_minimal_archive();
        std::fs::write(
            &locks_path,
            r#"{"version":2,"capture":"capture-001","rrdp":{},"rsync":{}}"#,
        )
        .expect("rewrite locks version");

        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(
                err,
                ReplayArchiveError::UnsupportedVersion {
                    entity: "payload replay locks",
                    version: 2
                }
            ),
            "{err}"
        );
    }

    #[test]
    fn replay_archive_index_reports_missing_rrdp_lock_and_missing_rsync_lock() {
        let (_temp, archive_root, locks_path, _notify_uri, _module_uri) = build_minimal_archive();
        let index =
            ReplayArchiveIndex::load(&archive_root, &locks_path).expect("load replay index");

        let err = index
            .require_rrdp_repo("https://missing.example/notification.xml")
            .unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::MissingRrdpLock(_)),
            "{err}"
        );

        let err = index
            .resolve_rsync_module_for_base_uri("rsync://missing.example/repo/path")
            .unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::MissingRsyncLock(_)),
            "{err}"
        );
    }

    #[test]
    fn replay_archive_index_rejects_rsync_meta_mismatch() {
        let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_minimal_archive();
        let mod_hash = sha256_hex(module_uri.as_bytes());
        let meta_path = archive_root
            .join("v1/captures/capture-001/rsync/modules")
            .join(mod_hash)
            .join("meta.json");
        std::fs::write(
            &meta_path,
            r#"{"version":1,"module":"rsync://other.example/repo/","createdAt":"2026-03-13T00:00:00Z","lastSeenAt":"2026-03-13T00:00:01Z"}"#,
        )
        .expect("rewrite rsync meta");

        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::RsyncMetaMismatch { .. }),
            "{err}"
        );
    }
    #[test]
    fn replay_archive_index_rejects_missing_rsync_module_tree() {
        let (_temp, archive_root, locks_path, _notify_uri, module_uri) = build_minimal_archive();
        let mod_hash = sha256_hex(module_uri.as_bytes());
        let tree_dir = archive_root
            .join("v1/captures/capture-001/rsync/modules")
            .join(mod_hash)
            .join("tree");
        std::fs::remove_dir_all(&tree_dir).expect("remove tree dir");

        let err = ReplayArchiveIndex::load(&archive_root, &locks_path).unwrap_err();
        assert!(
            matches!(err, ReplayArchiveError::MissingRsyncTree { .. }),
            "{err}"
        );
    }
}
