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

use crate::blob_store::{ExternalRawStoreDb, ExternalRepoBytesDb, RawObjectStore};
use crate::cir::model::CanonicalInputRepresentation;

#[derive(Debug, thiserror::Error)]
pub enum CirMaterializeError {
    #[error("invalid rsync URI: {0}")]
    InvalidRsyncUri(String),

    #[error("rsync URI must reference a file object, got directory-like URI: {0}")]
    DirectoryLikeRsyncUri(String),

    #[error("create mirror root failed: {path}: {detail}")]
    CreateMirrorRoot { path: String, detail: String },

    #[error("remove mirror root failed: {path}: {detail}")]
    RemoveMirrorRoot { path: String, detail: String },

    #[error("create parent directory failed: {path}: {detail}")]
    CreateParent { path: String, detail: String },

    #[error("remove existing target failed: {path}: {detail}")]
    RemoveExistingTarget { path: String, detail: String },

    #[error("static object not found for sha256={sha256_hex}")]
    MissingStaticObject { sha256_hex: String },

    #[error("link target failed: {src} -> {dst}: {detail}")]
    Link {
        src: String,
        dst: String,
        detail: String,
    },

    #[error("copy target failed: {src} -> {dst}: {detail}")]
    Copy {
        src: String,
        dst: String,
        detail: String,
    },

    #[error("mirror tree mismatch after materialize: {0}")]
    TreeMismatch(String),

    #[error("open raw store failed: {path}: {detail}")]
    OpenRawStore { path: String, detail: String },

    #[error("raw object not found for sha256={sha256_hex}")]
    MissingRawStoreObject { sha256_hex: String },

    #[error("read raw store failed for sha256={sha256_hex}: {detail}")]
    ReadRawStore { sha256_hex: String, detail: String },

    #[error("open repo bytes store failed: {path}: {detail}")]
    OpenRepoBytesStore { path: String, detail: String },

    #[error("repo bytes object not found for sha256={sha256_hex}")]
    MissingRepoBytesObject { sha256_hex: String },

    #[error("read repo bytes store failed for sha256={sha256_hex}: {detail}")]
    ReadRepoBytesStore { sha256_hex: String, detail: String },
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirMaterializeSummary {
    pub object_count: usize,
    pub trust_anchor_count: usize,
    pub materialized_file_count: usize,
    pub linked_files: usize,
    pub copied_files: usize,
}

pub fn materialize_cir(
    cir: &CanonicalInputRepresentation,
    static_root: &Path,
    mirror_root: &Path,
    clean_rebuild: bool,
) -> Result<CirMaterializeSummary, CirMaterializeError> {
    cir.validate().map_err(CirMaterializeError::TreeMismatch)?;

    prepare_mirror_root(mirror_root, clean_rebuild)?;

    let mut linked_files = 0usize;
    let mut copied_files = 0usize;

    for object in cir.validated_objects() {
        let sha256_hex = hex::encode(&object.sha256);
        let source = resolve_static_pool_file(static_root, &sha256_hex)?;
        let relative = mirror_relative_path_for_rsync_uri(&object.rsync_uri)?;
        let target = mirror_root.join(&relative);

        if let Some(parent) = target.parent() {
            fs::create_dir_all(parent).map_err(|e| CirMaterializeError::CreateParent {
                path: parent.display().to_string(),
                detail: e.to_string(),
            })?;
        }

        if target.exists() {
            fs::remove_file(&target).map_err(|e| CirMaterializeError::RemoveExistingTarget {
                path: target.display().to_string(),
                detail: e.to_string(),
            })?;
        }

        match fs::hard_link(&source, &target) {
            Ok(()) => linked_files += 1,
            Err(link_err) => {
                fs::copy(&source, &target).map_err(|copy_err| CirMaterializeError::Copy {
                    src: source.display().to_string(),
                    dst: target.display().to_string(),
                    detail: format!("{copy_err}; original link error: {link_err}"),
                })?;
                copied_files += 1;
            }
        }
    }

    for trust_anchor in &cir.trust_anchors {
        write_bytes_to_mirror_uri(
            mirror_root,
            &trust_anchor.ta_rsync_uri,
            &trust_anchor.ta_certificate_der,
            "cir trust anchor",
        )?;
        copied_files += 1;
    }

    let actual = collect_materialized_uris(mirror_root)?;
    let expected = expected_materialized_uris(cir);
    if actual != expected {
        return Err(CirMaterializeError::TreeMismatch(format!(
            "expected {} files, got {} files",
            expected.len(),
            actual.len()
        )));
    }

    Ok(CirMaterializeSummary {
        object_count: cir.validated_object_count(),
        trust_anchor_count: cir.trust_anchors.len(),
        materialized_file_count: expected.len(),
        linked_files,
        copied_files,
    })
}

pub fn materialize_cir_from_raw_store(
    cir: &CanonicalInputRepresentation,
    raw_store_db: &Path,
    mirror_root: &Path,
    clean_rebuild: bool,
) -> Result<CirMaterializeSummary, CirMaterializeError> {
    cir.validate().map_err(CirMaterializeError::TreeMismatch)?;

    prepare_mirror_root(mirror_root, clean_rebuild)?;

    let raw_store =
        ExternalRawStoreDb::open(raw_store_db).map_err(|e| CirMaterializeError::OpenRawStore {
            path: raw_store_db.display().to_string(),
            detail: e.to_string(),
        })?;

    let mut copied_files = 0usize;
    for object in cir.validated_objects() {
        let sha256_hex = hex::encode(&object.sha256);
        let bytes = raw_store
            .get_blob_bytes(&sha256_hex)
            .map_err(|e| CirMaterializeError::ReadRawStore {
                sha256_hex: sha256_hex.clone(),
                detail: e.to_string(),
            })?
            .ok_or_else(|| CirMaterializeError::MissingRawStoreObject {
                sha256_hex: sha256_hex.clone(),
            })?;
        write_bytes_to_mirror_uri(
            mirror_root,
            &object.rsync_uri,
            &bytes,
            &raw_store_db.display().to_string(),
        )?;
        copied_files += 1;
    }

    for trust_anchor in &cir.trust_anchors {
        write_bytes_to_mirror_uri(
            mirror_root,
            &trust_anchor.ta_rsync_uri,
            &trust_anchor.ta_certificate_der,
            "cir trust anchor",
        )?;
        copied_files += 1;
    }

    let actual = collect_materialized_uris(mirror_root)?;
    let expected = expected_materialized_uris(cir);
    if actual != expected {
        return Err(CirMaterializeError::TreeMismatch(format!(
            "expected {} files, got {} files",
            expected.len(),
            actual.len()
        )));
    }

    Ok(CirMaterializeSummary {
        object_count: cir.validated_object_count(),
        trust_anchor_count: cir.trust_anchors.len(),
        materialized_file_count: expected.len(),
        linked_files: 0,
        copied_files,
    })
}

pub fn materialize_cir_from_repo_bytes(
    cir: &CanonicalInputRepresentation,
    repo_bytes_db: &Path,
    mirror_root: &Path,
    clean_rebuild: bool,
) -> Result<CirMaterializeSummary, CirMaterializeError> {
    cir.validate().map_err(CirMaterializeError::TreeMismatch)?;

    prepare_mirror_root(mirror_root, clean_rebuild)?;

    let repo_bytes = ExternalRepoBytesDb::open(repo_bytes_db).map_err(|e| {
        CirMaterializeError::OpenRepoBytesStore {
            path: repo_bytes_db.display().to_string(),
            detail: e.to_string(),
        }
    })?;

    let mut copied_files = 0usize;
    for object in cir.validated_objects() {
        let sha256_hex = hex::encode(&object.sha256);
        let bytes = repo_bytes
            .get_blob_bytes(&sha256_hex)
            .map_err(|e| CirMaterializeError::ReadRepoBytesStore {
                sha256_hex: sha256_hex.clone(),
                detail: e.to_string(),
            })?
            .ok_or_else(|| CirMaterializeError::MissingRepoBytesObject {
                sha256_hex: sha256_hex.clone(),
            })?;
        write_bytes_to_mirror_uri(
            mirror_root,
            &object.rsync_uri,
            &bytes,
            &repo_bytes_db.display().to_string(),
        )?;
        copied_files += 1;
    }

    for trust_anchor in &cir.trust_anchors {
        write_bytes_to_mirror_uri(
            mirror_root,
            &trust_anchor.ta_rsync_uri,
            &trust_anchor.ta_certificate_der,
            "cir trust anchor",
        )?;
        copied_files += 1;
    }

    let actual = collect_materialized_uris(mirror_root)?;
    let expected = expected_materialized_uris(cir);
    if actual != expected {
        return Err(CirMaterializeError::TreeMismatch(format!(
            "expected {} files, got {} files",
            expected.len(),
            actual.len()
        )));
    }

    Ok(CirMaterializeSummary {
        object_count: cir.validated_object_count(),
        trust_anchor_count: cir.trust_anchors.len(),
        materialized_file_count: expected.len(),
        linked_files: 0,
        copied_files,
    })
}

fn prepare_mirror_root(mirror_root: &Path, clean_rebuild: bool) -> Result<(), CirMaterializeError> {
    if clean_rebuild && mirror_root.exists() {
        fs::remove_dir_all(mirror_root).map_err(|e| CirMaterializeError::RemoveMirrorRoot {
            path: mirror_root.display().to_string(),
            detail: e.to_string(),
        })?;
    }
    fs::create_dir_all(mirror_root).map_err(|e| CirMaterializeError::CreateMirrorRoot {
        path: mirror_root.display().to_string(),
        detail: e.to_string(),
    })
}

fn write_bytes_to_mirror_uri(
    mirror_root: &Path,
    rsync_uri: &str,
    bytes: &[u8],
    src_label: &str,
) -> Result<(), CirMaterializeError> {
    let relative = mirror_relative_path_for_rsync_uri(rsync_uri)?;
    let target = mirror_root.join(&relative);

    if let Some(parent) = target.parent() {
        fs::create_dir_all(parent).map_err(|e| CirMaterializeError::CreateParent {
            path: parent.display().to_string(),
            detail: e.to_string(),
        })?;
    }

    if target.exists() {
        fs::remove_file(&target).map_err(|e| CirMaterializeError::RemoveExistingTarget {
            path: target.display().to_string(),
            detail: e.to_string(),
        })?;
    }

    fs::write(&target, bytes).map_err(|e| CirMaterializeError::Copy {
        src: src_label.to_string(),
        dst: target.display().to_string(),
        detail: e.to_string(),
    })
}

fn expected_materialized_uris(cir: &CanonicalInputRepresentation) -> BTreeSet<String> {
    cir.validated_objects()
        .map(|item| item.rsync_uri.clone())
        .chain(
            cir.trust_anchors
                .iter()
                .map(|item| item.ta_rsync_uri.clone()),
        )
        .collect()
}

pub fn mirror_relative_path_for_rsync_uri(rsync_uri: &str) -> Result<PathBuf, CirMaterializeError> {
    let url = url::Url::parse(rsync_uri)
        .map_err(|_| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))?;
    if url.scheme() != "rsync" {
        return Err(CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()));
    }
    let host = url
        .host_str()
        .ok_or_else(|| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))?;
    let segments = url
        .path_segments()
        .ok_or_else(|| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))?
        .collect::<Vec<_>>();
    if segments.is_empty() || segments.last().copied().unwrap_or_default().is_empty() {
        return Err(CirMaterializeError::DirectoryLikeRsyncUri(
            rsync_uri.to_string(),
        ));
    }

    let mut path = PathBuf::from(host);
    for segment in segments {
        if !segment.is_empty() {
            path.push(segment);
        }
    }
    Ok(path)
}

pub fn resolve_static_pool_file(
    static_root: &Path,
    sha256_hex: &str,
) -> Result<PathBuf, CirMaterializeError> {
    if sha256_hex.len() != 64 || !sha256_hex.as_bytes().iter().all(u8::is_ascii_hexdigit) {
        return Err(CirMaterializeError::MissingStaticObject {
            sha256_hex: sha256_hex.to_string(),
        });
    }
    let prefix1 = &sha256_hex[0..2];
    let prefix2 = &sha256_hex[2..4];

    let entries =
        fs::read_dir(static_root).map_err(|_| CirMaterializeError::MissingStaticObject {
            sha256_hex: sha256_hex.to_string(),
        })?;
    let mut dates = entries
        .filter_map(Result::ok)
        .filter(|entry| entry.path().is_dir())
        .map(|entry| entry.path())
        .collect::<Vec<_>>();
    dates.sort();

    for date_dir in dates {
        let candidate = date_dir.join(prefix1).join(prefix2).join(sha256_hex);
        if candidate.is_file() {
            return Ok(candidate);
        }
    }
    Err(CirMaterializeError::MissingStaticObject {
        sha256_hex: sha256_hex.to_string(),
    })
}

fn collect_materialized_uris(mirror_root: &Path) -> Result<BTreeSet<String>, CirMaterializeError> {
    let mut out = BTreeSet::new();
    let mut stack = vec![mirror_root.to_path_buf()];
    while let Some(path) = stack.pop() {
        for entry in fs::read_dir(&path).map_err(|e| CirMaterializeError::CreateMirrorRoot {
            path: path.display().to_string(),
            detail: e.to_string(),
        })? {
            let entry = entry.map_err(|e| CirMaterializeError::CreateMirrorRoot {
                path: path.display().to_string(),
                detail: e.to_string(),
            })?;
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else {
                let rel = path
                    .strip_prefix(mirror_root)
                    .expect("materialized path under mirror root")
                    .to_string_lossy()
                    .replace('\\', "/");
                let uri = format!("rsync://{rel}");
                out.insert(uri);
            }
        }
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::{
        CirMaterializeError, materialize_cir, materialize_cir_from_raw_store,
        materialize_cir_from_repo_bytes, mirror_relative_path_for_rsync_uri,
        resolve_static_pool_file,
    };
    use crate::blob_store::{ExternalRawStoreDb, ExternalRepoBytesDb};
    use crate::cir::model::{CanonicalInputRepresentation, CirObject, CirTrustAnchor, sha256};
    use sha2::Digest;
    use std::path::{Path, PathBuf};

    fn sample_time() -> time::OffsetDateTime {
        time::OffsetDateTime::parse(
            "2026-04-07T12:34:56Z",
            &time::format_description::well_known::Rfc3339,
        )
        .unwrap()
    }

    fn sample_trust_anchor() -> CirTrustAnchor {
        let ta_rsync_uri = "rsync://example.net/repo/ta.cer";
        let ta_certificate_der = b"ta-der".to_vec();
        CirTrustAnchor {
            ta_rsync_uri: ta_rsync_uri.to_string(),
            tal_uri: "https://tal.example.net/root.tal".to_string(),
            tal_bytes: format!("{ta_rsync_uri}\n\nAQID\n").into_bytes(),
            ta_certificate_sha256: sha256(&ta_certificate_der),
            ta_certificate_der,
        }
    }

    fn sample_cir() -> CanonicalInputRepresentation {
        CanonicalInputRepresentation::new_v4(
            sample_time(),
            vec![
                CirObject {
                    rsync_uri: "rsync://example.net/repo/a.cer".to_string(),
                    sha256: hex::decode(
                        "1111111111111111111111111111111111111111111111111111111111111111",
                    )
                    .unwrap(),
                },
                CirObject {
                    rsync_uri: "rsync://example.net/repo/nested/b.roa".to_string(),
                    sha256: hex::decode(
                        "2222222222222222222222222222222222222222222222222222222222222222",
                    )
                    .unwrap(),
                },
            ],
            Vec::new(),
            vec![sample_trust_anchor()],
            Vec::new(),
            Vec::new(),
        )
    }

    fn cir_with_real_hashes(a: &[u8], b: &[u8]) -> CanonicalInputRepresentation {
        CanonicalInputRepresentation::new_v4(
            sample_time(),
            vec![
                CirObject {
                    rsync_uri: "rsync://example.net/repo/a.cer".to_string(),
                    sha256: sha2::Sha256::digest(a).to_vec(),
                },
                CirObject {
                    rsync_uri: "rsync://example.net/repo/nested/b.roa".to_string(),
                    sha256: sha2::Sha256::digest(b).to_vec(),
                },
            ],
            Vec::new(),
            vec![sample_trust_anchor()],
            Vec::new(),
            Vec::new(),
        )
    }

    #[test]
    fn mirror_relative_path_for_rsync_uri_maps_host_and_path() {
        let path =
            mirror_relative_path_for_rsync_uri("rsync://example.net/repo/nested/b.roa").unwrap();
        assert_eq!(
            path,
            PathBuf::from("example.net")
                .join("repo")
                .join("nested")
                .join("b.roa")
        );
    }

    #[test]
    fn resolve_static_pool_file_finds_hash_across_dates() {
        let td = tempfile::tempdir().unwrap();
        let path = td.path().join("20260407").join("11").join("11");
        std::fs::create_dir_all(&path).unwrap();
        let file = path.join("1111111111111111111111111111111111111111111111111111111111111111");
        std::fs::write(&file, b"x").unwrap();

        let resolved = resolve_static_pool_file(
            td.path(),
            "1111111111111111111111111111111111111111111111111111111111111111",
        )
        .unwrap();
        assert_eq!(resolved, file);
    }

    #[test]
    fn resolve_static_pool_file_rejects_invalid_hash_and_missing_hash() {
        let td = tempfile::tempdir().unwrap();
        let err = resolve_static_pool_file(td.path(), "not-a-hash")
            .expect_err("invalid hash should fail");
        assert!(matches!(
            err,
            CirMaterializeError::MissingStaticObject { .. }
        ));

        let err = resolve_static_pool_file(
            td.path(),
            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
        )
        .expect_err("missing hash should fail");
        assert!(matches!(
            err,
            CirMaterializeError::MissingStaticObject { .. }
        ));
    }

    #[test]
    fn mirror_relative_path_rejects_non_rsync_and_directory_like_uris() {
        let err = mirror_relative_path_for_rsync_uri("https://example.net/repo/a.roa")
            .expect_err("non-rsync uri must fail");
        assert!(matches!(err, CirMaterializeError::InvalidRsyncUri(_)));

        let err = mirror_relative_path_for_rsync_uri("rsync://example.net/repo/")
            .expect_err("directory-like uri must fail");
        assert!(matches!(err, CirMaterializeError::DirectoryLikeRsyncUri(_)));
    }

    #[test]
    fn materialize_clean_rebuild_creates_exact_tree_and_removes_stale_files() {
        let td = tempfile::tempdir().unwrap();
        let static_root = td.path().join("static");
        let mirror_root = td.path().join("mirror");

        write_static(
            &static_root,
            "20260407",
            "1111111111111111111111111111111111111111111111111111111111111111",
            b"a",
        );
        write_static(
            &static_root,
            "20260407",
            "2222222222222222222222222222222222222222222222222222222222222222",
            b"b",
        );
        std::fs::create_dir_all(mirror_root.join("stale")).unwrap();
        std::fs::write(mirror_root.join("stale/old.txt"), b"old").unwrap();

        let summary = materialize_cir(&sample_cir(), &static_root, &mirror_root, true).unwrap();
        assert_eq!(summary.object_count, 2);
        assert_eq!(summary.trust_anchor_count, 1);
        assert_eq!(summary.materialized_file_count, 3);
        assert_eq!(
            std::fs::read(mirror_root.join("example.net/repo/a.cer")).unwrap(),
            b"a"
        );
        assert_eq!(
            std::fs::read(mirror_root.join("example.net/repo/nested/b.roa")).unwrap(),
            b"b"
        );
        assert_eq!(
            std::fs::read(mirror_root.join("example.net/repo/ta.cer")).unwrap(),
            b"ta-der"
        );
        assert!(!mirror_root.join("stale/old.txt").exists());
    }

    #[test]
    fn materialize_fails_when_static_object_missing() {
        let td = tempfile::tempdir().unwrap();
        let err = materialize_cir(&sample_cir(), td.path(), &td.path().join("mirror"), true)
            .expect_err("missing static object must fail");
        assert!(matches!(
            err,
            CirMaterializeError::MissingStaticObject { .. }
        ));
    }

    #[test]
    fn materialize_without_clean_rebuild_detects_stale_extra_files() {
        let td = tempfile::tempdir().unwrap();
        let static_root = td.path().join("static");
        let mirror_root = td.path().join("mirror");

        write_static(
            &static_root,
            "20260407",
            "1111111111111111111111111111111111111111111111111111111111111111",
            b"a",
        );
        write_static(
            &static_root,
            "20260407",
            "2222222222222222222222222222222222222222222222222222222222222222",
            b"b",
        );
        std::fs::create_dir_all(mirror_root.join("extra")).unwrap();
        std::fs::write(mirror_root.join("extra/stale.txt"), b"stale").unwrap();

        let err = materialize_cir(&sample_cir(), &static_root, &mirror_root, false)
            .expect_err("stale extra files should fail exact tree check");
        assert!(matches!(err, CirMaterializeError::TreeMismatch(_)));
    }

    #[test]
    fn materialize_from_raw_store_creates_expected_tree() {
        let td = tempfile::tempdir().unwrap();
        let raw_store_path = td.path().join("raw-store.db");
        let mirror_root = td.path().join("mirror");
        let a = b"a".to_vec();
        let b = b"b".to_vec();
        let cir = cir_with_real_hashes(&a, &b);

        {
            let raw_store = ExternalRawStoreDb::open(&raw_store_path).unwrap();
            raw_store
                .put_blob_bytes_batch(&[
                    (hex::encode(&cir.fresh_validated_objects[0].sha256), a),
                    (hex::encode(&cir.fresh_validated_objects[1].sha256), b),
                ])
                .unwrap();
        }

        let summary =
            materialize_cir_from_raw_store(&cir, &raw_store_path, &mirror_root, true).unwrap();
        assert_eq!(summary.object_count, 2);
        assert_eq!(summary.trust_anchor_count, 1);
        assert_eq!(summary.materialized_file_count, 3);
        assert_eq!(summary.linked_files, 0);
        assert_eq!(summary.copied_files, 3);
        assert_eq!(
            std::fs::read(mirror_root.join("example.net/repo/a.cer")).unwrap(),
            b"a"
        );
        assert_eq!(
            std::fs::read(mirror_root.join("example.net/repo/nested/b.roa")).unwrap(),
            b"b"
        );
        assert_eq!(
            std::fs::read(mirror_root.join("example.net/repo/ta.cer")).unwrap(),
            b"ta-der"
        );
    }

    #[test]
    fn materialize_from_raw_store_fails_when_object_missing() {
        let td = tempfile::tempdir().unwrap();
        let raw_store_path = td.path().join("raw-store.db");
        let mirror_root = td.path().join("mirror");
        let cir = cir_with_real_hashes(b"a", b"b");
        {
            let raw_store = ExternalRawStoreDb::open(&raw_store_path).unwrap();
            raw_store
                .put_blob_bytes_batch(&[(
                    hex::encode(&cir.fresh_validated_objects[0].sha256),
                    b"a".to_vec(),
                )])
                .unwrap();
        }

        let err = materialize_cir_from_raw_store(&cir, &raw_store_path, &mirror_root, true)
            .expect_err("missing second object should fail");
        assert!(matches!(
            err,
            CirMaterializeError::MissingRawStoreObject { .. }
        ));
    }

    #[test]
    fn materialize_from_raw_store_detects_stale_tree_when_not_clean_rebuild() {
        let td = tempfile::tempdir().unwrap();
        let raw_store_path = td.path().join("raw-store.db");
        let mirror_root = td.path().join("mirror");
        let cir = cir_with_real_hashes(b"a", b"b");
        {
            let raw_store = ExternalRawStoreDb::open(&raw_store_path).unwrap();
            raw_store
                .put_blob_bytes_batch(&[
                    (
                        hex::encode(&cir.fresh_validated_objects[0].sha256),
                        b"a".to_vec(),
                    ),
                    (
                        hex::encode(&cir.fresh_validated_objects[1].sha256),
                        b"b".to_vec(),
                    ),
                ])
                .unwrap();
        }
        std::fs::create_dir_all(mirror_root.join("extra")).unwrap();
        std::fs::write(mirror_root.join("extra/stale.txt"), b"stale").unwrap();

        let err = materialize_cir_from_raw_store(&cir, &raw_store_path, &mirror_root, false)
            .expect_err("stale file should fail exact tree check");
        assert!(matches!(err, CirMaterializeError::TreeMismatch(_)));
    }

    #[test]
    fn materialize_from_raw_store_overwrites_existing_targets() {
        let td = tempfile::tempdir().unwrap();
        let raw_store_path = td.path().join("raw-store.db");
        let mirror_root = td.path().join("mirror");
        let a = b"new-a".to_vec();
        let b = b"new-b".to_vec();
        let cir = cir_with_real_hashes(&a, &b);
        {
            let raw_store = ExternalRawStoreDb::open(&raw_store_path).unwrap();
            raw_store
                .put_blob_bytes_batch(&[
                    (
                        hex::encode(&cir.fresh_validated_objects[0].sha256),
                        a.clone(),
                    ),
                    (
                        hex::encode(&cir.fresh_validated_objects[1].sha256),
                        b.clone(),
                    ),
                ])
                .unwrap();
        }
        let target = mirror_root.join("example.net/repo/a.cer");
        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
        std::fs::write(&target, b"old").unwrap();

        let summary =
            materialize_cir_from_raw_store(&cir, &raw_store_path, &mirror_root, false).unwrap();
        assert_eq!(summary.copied_files, 3);
        assert_eq!(std::fs::read(&target).unwrap(), a);
    }

    #[test]
    fn materialize_from_raw_store_ignores_corrupt_raw_entry_when_blob_exists() {
        let td = tempfile::tempdir().unwrap();
        let raw_store_path = td.path().join("raw-store.db");
        let mirror_root = td.path().join("mirror");
        let cir = CanonicalInputRepresentation::new_v4(
            sample_time(),
            vec![CirObject {
                rsync_uri: "rsync://example.net/repo/a.cer".to_string(),
                sha256: hex::decode(
                    "1111111111111111111111111111111111111111111111111111111111111111",
                )
                .unwrap(),
            }],
            Vec::new(),
            vec![sample_trust_anchor()],
            Vec::new(),
            Vec::new(),
        );
        {
            let raw_store = ExternalRawStoreDb::open(&raw_store_path).unwrap();
            raw_store
                .put_blob_bytes_batch(&[(
                    "1111111111111111111111111111111111111111111111111111111111111111".to_string(),
                    b"blob-a".to_vec(),
                )])
                .unwrap();
        }
        {
            let db = rocksdb::DB::open_default(&raw_store_path).unwrap();
            db.put(
                b"rawbyhash:1111111111111111111111111111111111111111111111111111111111111111",
                b"bad-cbor",
            )
            .unwrap();
        }

        let summary =
            materialize_cir_from_raw_store(&cir, &raw_store_path, &mirror_root, true).unwrap();
        assert_eq!(summary.object_count, 1);
        assert_eq!(summary.materialized_file_count, 2);
        assert_eq!(
            std::fs::read(mirror_root.join("example.net/repo/a.cer")).unwrap(),
            b"blob-a"
        );
        assert_eq!(
            std::fs::read(mirror_root.join("example.net/repo/ta.cer")).unwrap(),
            b"ta-der"
        );
    }

    #[test]
    fn materialize_from_repo_bytes_creates_expected_tree() {
        let td = tempfile::tempdir().unwrap();
        let repo_bytes_db = td.path().join("repo-bytes.db");
        let mirror_root = td.path().join("mirror");
        let a = b"a".to_vec();
        let b = b"b".to_vec();
        let cir = cir_with_real_hashes(&a, &b);

        {
            let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_db).unwrap();
            repo_bytes
                .put_blob_bytes_batch(&[
                    (hex::encode(&cir.fresh_validated_objects[0].sha256), a),
                    (hex::encode(&cir.fresh_validated_objects[1].sha256), b),
                ])
                .unwrap();
        }

        let summary =
            materialize_cir_from_repo_bytes(&cir, &repo_bytes_db, &mirror_root, true).unwrap();
        assert_eq!(summary.object_count, 2);
        assert_eq!(summary.trust_anchor_count, 1);
        assert_eq!(summary.materialized_file_count, 3);
        assert_eq!(summary.linked_files, 0);
        assert_eq!(summary.copied_files, 3);
        assert_eq!(
            std::fs::read(mirror_root.join("example.net/repo/a.cer")).unwrap(),
            b"a"
        );
        assert_eq!(
            std::fs::read(mirror_root.join("example.net/repo/nested/b.roa")).unwrap(),
            b"b"
        );
        assert_eq!(
            std::fs::read(mirror_root.join("example.net/repo/ta.cer")).unwrap(),
            b"ta-der"
        );
    }

    fn write_static(root: &Path, date: &str, hash: &str, bytes: &[u8]) {
        let path = root.join(date).join(&hash[0..2]).join(&hash[2..4]);
        std::fs::create_dir_all(&path).unwrap();
        std::fs::write(path.join(hash), bytes).unwrap();
    }
}
