use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::Path;

use crate::audit::{AuditObjectResult, ObjectAuditEntry, PublicationPointAudit};
use crate::cir::accumulator::CirInputSnapshot;
use crate::cir::encode::{CirEncodeError, encode_cir};
use crate::cir::model::{
    CanonicalInputRepresentation, CirObject, CirRejectedObject, CirTrustAnchor,
};
use crate::cir::static_pool::{
    CirStaticPoolError, CirStaticPoolExportSummary, export_hashes_from_store,
};
use crate::current_repo_index::CurrentRepoObject;
use crate::data_model::ta::TrustAnchor;
use crate::storage::RocksStore;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirExportTiming {
    pub build_cir_ms: u64,
    pub write_cir_ms: u64,
    pub total_ms: u64,
}

#[derive(Debug, thiserror::Error)]
pub enum CirExportError {
    #[error("CIR TAL URI must be http(s), got: {0}")]
    InvalidTalUri(String),

    #[error("TAL does not contain any rsync TA URI; CIR replay scheme A requires one")]
    MissingTaRsyncUri,

    #[error("CIR model validation failed: {0}")]
    Validate(String),

    #[error("CIR consumed audit has conflicting hashes for {rsync_uri}: {first} vs {second}")]
    ConflictingObjectHash {
        rsync_uri: String,
        first: String,
        second: String,
    },

    #[error("encode CIR failed: {0}")]
    Encode(#[from] CirEncodeError),

    #[error("static pool export failed: {0}")]
    StaticPool(#[from] CirStaticPoolError),

    #[error("write CIR file failed: {0}: {1}")]
    Write(String, String),
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirRawStoreExportSummary {
    pub unique_hashes: usize,
    pub written_entries: usize,
    pub reused_entries: usize,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirExportSummary {
    pub object_count: usize,
    pub trust_anchor_count: usize,
    pub timing: CirExportTiming,
}

#[derive(Clone, Copy, Debug)]
pub struct CirTrustAnchorBinding<'a> {
    pub trust_anchor: &'a TrustAnchor,
    pub tal_uri: &'a str,
}

fn is_sha256_hex(value: &str) -> bool {
    value.len() == 64 && value.as_bytes().iter().all(u8::is_ascii_hexdigit)
}

fn insert_consumed_object_hash(
    objects: &mut BTreeMap<String, String>,
    rsync_uri: &str,
    sha256_hex: &str,
) -> Result<(), CirExportError> {
    if !rsync_uri.starts_with("rsync://") || !is_sha256_hex(sha256_hex) {
        return Ok(());
    }

    let normalized = sha256_hex.to_ascii_lowercase();
    if let Some(existing) = objects.get(rsync_uri) {
        if existing != &normalized {
            return Err(CirExportError::ConflictingObjectHash {
                rsync_uri: rsync_uri.to_string(),
                first: existing.clone(),
                second: normalized,
            });
        }
        return Ok(());
    }

    objects.insert(rsync_uri.to_string(), normalized);
    Ok(())
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CirObjectSection {
    Fresh,
    Cached,
}

fn publication_point_is_cached_fallback(pp: &PublicationPointAudit) -> bool {
    pp.source == "vcir_current_instance" || pp.repo_terminal_state == "fallback_current_instance"
}

fn publication_point_cir_entries<'a>(
    pp: &'a PublicationPointAudit,
    section: CirObjectSection,
) -> &'a [ObjectAuditEntry] {
    match (section, publication_point_is_cached_fallback(pp)) {
        (CirObjectSection::Fresh, false) => &pp.objects,
        (CirObjectSection::Cached, true) => &pp.objects,
        _ => &[],
    }
}

fn collect_cir_objects_from_validation_audit(
    publication_points: &[PublicationPointAudit],
    section: CirObjectSection,
) -> Result<BTreeMap<String, String>, CirExportError> {
    let mut objects = BTreeMap::new();
    for pp in publication_points {
        for obj in publication_point_cir_entries(pp, section) {
            if !matches!(obj.result, AuditObjectResult::Ok | AuditObjectResult::Error) {
                continue;
            }
            insert_consumed_object_hash(&mut objects, &obj.rsync_uri, &obj.sha256_hex)?;
        }
    }
    Ok(objects)
}

fn collect_rejected_objects_from_validation_audit(
    publication_points: &[PublicationPointAudit],
    section: CirObjectSection,
) -> Vec<CirRejectedObject> {
    let mut rejected_objects = publication_points
        .iter()
        .flat_map(|pp| publication_point_cir_entries(pp, section).iter())
        .filter(|item| item.result == AuditObjectResult::Error)
        .filter(|item| item.rsync_uri.starts_with("rsync://"))
        .map(|item| CirRejectedObject {
            object_uri: item.rsync_uri.clone(),
            reason: item.detail.clone(),
        })
        .collect::<Vec<_>>();
    rejected_objects.sort_by(|a, b| a.object_uri.cmp(&b.object_uri));
    rejected_objects.dedup_by(|a, b| a.object_uri == b.object_uri);
    rejected_objects
}

fn cir_objects_from_hash_map(objects: BTreeMap<String, String>) -> Vec<CirObject> {
    objects
        .into_iter()
        .map(|(rsync_uri, sha256_hex)| CirObject {
            rsync_uri,
            sha256: hex::decode(sha256_hex).expect("validated hex"),
        })
        .collect()
}

fn canonical_ta_rsync_uri(trust_anchor: &TrustAnchor) -> Result<String, CirExportError> {
    if let Some(uri) = &trust_anchor.resolved_ta_uri
        && uri.scheme() == "rsync"
    {
        return Ok(uri.as_str().to_string());
    }
    trust_anchor
        .tal
        .ta_uris
        .iter()
        .filter(|uri| uri.scheme() == "rsync")
        .map(|uri| uri.as_str().to_string())
        .min()
        .ok_or(CirExportError::MissingTaRsyncUri)
}

fn build_cir_trust_anchors(
    tal_bindings: &[CirTrustAnchorBinding<'_>],
) -> Result<Vec<CirTrustAnchor>, CirExportError> {
    for binding in tal_bindings {
        if !(binding.tal_uri.starts_with("https://") || binding.tal_uri.starts_with("http://")) {
            return Err(CirExportError::InvalidTalUri(binding.tal_uri.to_string()));
        }
    }

    let mut trust_anchors = Vec::with_capacity(tal_bindings.len());
    for binding in tal_bindings {
        let ta_rsync_uri = canonical_ta_rsync_uri(binding.trust_anchor)?;
        let ta_certificate_der = binding.trust_anchor.ta_certificate.raw_der.clone();
        trust_anchors.push(CirTrustAnchor {
            ta_rsync_uri,
            tal_uri: binding.tal_uri.to_string(),
            tal_bytes: binding.trust_anchor.tal.raw.clone(),
            ta_certificate_sha256: crate::cir::model::sha256(&ta_certificate_der),
            ta_certificate_der,
        });
    }
    trust_anchors.sort_by(|a, b| a.ta_rsync_uri.cmp(&b.ta_rsync_uri));
    Ok(trust_anchors)
}

pub fn build_cir_from_run(
    store: &RocksStore,
    trust_anchor: &TrustAnchor,
    tal_uri: &str,
    validation_time: time::OffsetDateTime,
    publication_points: &[PublicationPointAudit],
) -> Result<CanonicalInputRepresentation, CirExportError> {
    build_cir_from_run_multi(
        store,
        &[CirTrustAnchorBinding {
            trust_anchor,
            tal_uri,
        }],
        validation_time,
        publication_points,
        None,
    )
}

pub fn build_cir_from_run_multi(
    _store: &RocksStore,
    tal_bindings: &[CirTrustAnchorBinding<'_>],
    validation_time: time::OffsetDateTime,
    publication_points: &[PublicationPointAudit],
    _current_repo_objects: Option<&[CurrentRepoObject]>,
) -> Result<CanonicalInputRepresentation, CirExportError> {
    let fresh_objects =
        collect_cir_objects_from_validation_audit(publication_points, CirObjectSection::Fresh)?;
    let cached_objects =
        collect_cir_objects_from_validation_audit(publication_points, CirObjectSection::Cached)?;

    let trust_anchors = build_cir_trust_anchors(tal_bindings)?;

    let fresh_rejected_objects =
        collect_rejected_objects_from_validation_audit(publication_points, CirObjectSection::Fresh);
    let cached_rejected_objects = collect_rejected_objects_from_validation_audit(
        publication_points,
        CirObjectSection::Cached,
    );

    let cir = CanonicalInputRepresentation::new_v4(
        validation_time,
        cir_objects_from_hash_map(fresh_objects),
        cir_objects_from_hash_map(cached_objects),
        trust_anchors,
        fresh_rejected_objects,
        cached_rejected_objects,
    );
    cir.validate().map_err(CirExportError::Validate)?;
    Ok(cir)
}

pub fn build_cir_from_input_snapshot_multi(
    tal_bindings: &[CirTrustAnchorBinding<'_>],
    validation_time: time::OffsetDateTime,
    input: CirInputSnapshot,
) -> Result<CanonicalInputRepresentation, CirExportError> {
    let trust_anchors = build_cir_trust_anchors(tal_bindings)?;
    Ok(CanonicalInputRepresentation::new_v4(
        validation_time,
        input.fresh_validated_objects,
        input.cached_validated_objects,
        trust_anchors,
        input.fresh_rejected_objects,
        input.cached_rejected_objects,
    ))
}

pub fn write_cir_file(
    path: &Path,
    cir: &CanonicalInputRepresentation,
) -> Result<(), CirExportError> {
    let der = encode_cir(cir)?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| CirExportError::Write(path.display().to_string(), e.to_string()))?;
    }
    std::fs::write(path, der)
        .map_err(|e| CirExportError::Write(path.display().to_string(), e.to_string()))
}

pub fn export_cir_static_pool(
    store: &RocksStore,
    static_root: &Path,
    capture_date_utc: time::Date,
    cir: &CanonicalInputRepresentation,
    trust_anchors: &[&TrustAnchor],
) -> Result<CirStaticPoolExportSummary, CirExportError> {
    let _ = trust_anchors;
    let hashes = cir
        .validated_objects()
        .map(|item| hex::encode(&item.sha256))
        .collect::<Vec<_>>();
    export_hashes_from_store(store, static_root, capture_date_utc, &hashes).map_err(Into::into)
}

pub fn export_cir_raw_store(
    store: &RocksStore,
    raw_store_path: &Path,
    cir: &CanonicalInputRepresentation,
    trust_anchors: &[&TrustAnchor],
) -> Result<CirRawStoreExportSummary, CirExportError> {
    let _ = trust_anchors;
    let unique: BTreeSet<String> = cir
        .validated_objects()
        .map(|item| hex::encode(&item.sha256))
        .collect();

    let written_entries = 0usize;
    let mut reused_entries = 0usize;
    for sha256_hex in &unique {
        if store
            .get_blob_bytes(sha256_hex)
            .map_err(|e| {
                CirExportError::Write(raw_store_path.display().to_string(), e.to_string())
            })?
            .is_some()
        {
            reused_entries += 1;
            continue;
        }
        return Err(CirExportError::Write(
            raw_store_path.display().to_string(),
            format!("raw store missing object for sha256={sha256_hex}"),
        ));
    }

    Ok(CirRawStoreExportSummary {
        unique_hashes: unique.len(),
        written_entries,
        reused_entries,
    })
}

pub fn export_cir_from_run(
    store: &RocksStore,
    trust_anchor: &TrustAnchor,
    tal_uri: &str,
    validation_time: time::OffsetDateTime,
    publication_points: &[PublicationPointAudit],
    cir_out: &Path,
    capture_date_utc: time::Date,
) -> Result<CirExportSummary, CirExportError> {
    export_cir_from_run_multi(
        store,
        &[CirTrustAnchorBinding {
            trust_anchor,
            tal_uri,
        }],
        validation_time,
        publication_points,
        cir_out,
        capture_date_utc,
        None,
    )
}

pub fn export_cir_from_run_multi(
    store: &RocksStore,
    tal_bindings: &[CirTrustAnchorBinding<'_>],
    validation_time: time::OffsetDateTime,
    publication_points: &[PublicationPointAudit],
    cir_out: &Path,
    capture_date_utc: time::Date,
    current_repo_objects: Option<&[CurrentRepoObject]>,
) -> Result<CirExportSummary, CirExportError> {
    let _ = capture_date_utc;
    let total_started = std::time::Instant::now();

    let started = std::time::Instant::now();
    let cir = build_cir_from_run_multi(
        store,
        tal_bindings,
        validation_time,
        publication_points,
        current_repo_objects,
    )?;
    let build_cir_ms = started.elapsed().as_millis() as u64;

    let _ = store;

    let started = std::time::Instant::now();
    write_cir_file(cir_out, &cir)?;
    let write_cir_ms = started.elapsed().as_millis() as u64;

    Ok(CirExportSummary {
        object_count: cir.validated_object_count(),
        trust_anchor_count: cir.trust_anchors.len(),
        timing: CirExportTiming {
            build_cir_ms,
            write_cir_ms,
            total_ms: total_started.elapsed().as_millis() as u64,
        },
    })
}

pub fn export_cir_from_input_snapshot_multi(
    tal_bindings: &[CirTrustAnchorBinding<'_>],
    validation_time: time::OffsetDateTime,
    input: CirInputSnapshot,
    cir_out: &Path,
) -> Result<CirExportSummary, CirExportError> {
    let total_started = std::time::Instant::now();

    let started = std::time::Instant::now();
    let cir = build_cir_from_input_snapshot_multi(tal_bindings, validation_time, input)?;
    let build_cir_ms = started.elapsed().as_millis() as u64;

    let started = std::time::Instant::now();
    write_cir_file(cir_out, &cir)?;
    let write_cir_ms = started.elapsed().as_millis() as u64;

    Ok(CirExportSummary {
        object_count: cir.validated_object_count(),
        trust_anchor_count: cir.trust_anchors.len(),
        timing: CirExportTiming {
            build_cir_ms,
            write_cir_ms,
            total_ms: total_started.elapsed().as_millis() as u64,
        },
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cir::decode::decode_cir;
    use crate::data_model::ta::TrustAnchor;
    use crate::data_model::tal::Tal;
    use crate::storage::{RawByHashEntry, RocksStore};

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

    fn sample_date() -> time::Date {
        time::Date::from_calendar_date(2026, time::Month::April, 7).unwrap()
    }

    fn sample_trust_anchor() -> TrustAnchor {
        let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let tal_bytes =
            std::fs::read(base.join("tests/fixtures/tal/apnic-rfc7730-https.tal")).unwrap();
        let ta_der = std::fs::read(base.join("tests/fixtures/ta/apnic-ta.cer")).unwrap();
        let tal = Tal::decode_bytes(&tal_bytes).unwrap();
        TrustAnchor::bind_der(tal, &ta_der, None).unwrap()
    }

    fn sample_arin_trust_anchor() -> TrustAnchor {
        let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let tal_bytes = std::fs::read(base.join("tests/fixtures/tal/arin.tal")).unwrap();
        let ta_der = std::fs::read(base.join("tests/fixtures/ta/arin-ta.cer")).unwrap();
        let tal = Tal::decode_bytes(&tal_bytes).unwrap();
        TrustAnchor::bind_der(tal, &ta_der, None).unwrap()
    }

    fn sample_trust_anchor_without_rsync_uri() -> TrustAnchor {
        let mut ta = sample_trust_anchor();
        ta.tal.ta_uris.retain(|uri| uri.scheme() != "rsync");
        ta
    }

    fn sha256_hex(bytes: &[u8]) -> String {
        use sha2::{Digest, Sha256};
        hex::encode(Sha256::digest(bytes))
    }

    fn audit_entry(
        uri: &str,
        hash: &str,
        kind: crate::audit::AuditObjectKind,
        result: crate::audit::AuditObjectResult,
        detail: Option<&str>,
    ) -> crate::audit::ObjectAuditEntry {
        crate::audit::ObjectAuditEntry {
            rsync_uri: uri.to_string(),
            sha256_hex: hash.to_string(),
            kind,
            result,
            detail: detail.map(ToString::to_string),
        }
    }

    #[test]
    fn build_cir_from_run_collects_consumed_audit_objects_and_tal() {
        let td = tempfile::tempdir().unwrap();
        let store = RocksStore::open(td.path()).unwrap();
        let bytes = b"object-a".to_vec();
        let hash = sha256_hex(&bytes);
        let publication_points = vec![PublicationPointAudit {
            objects: vec![audit_entry(
                "rsync://example.test/repo/a.cer",
                &hash,
                crate::audit::AuditObjectKind::Certificate,
                crate::audit::AuditObjectResult::Ok,
                None,
            )],
            ..PublicationPointAudit::default()
        }];

        let ta = sample_trust_anchor();
        let cir = build_cir_from_run(
            &store,
            &ta,
            "https://example.test/root.tal",
            sample_time(),
            &publication_points,
        )
        .expect("build cir");
        assert_eq!(cir.version, crate::cir::model::CIR_VERSION_V4);
        assert_eq!(cir.trust_anchors.len(), 1);
        assert_eq!(
            cir.trust_anchors[0].tal_uri,
            "https://example.test/root.tal"
        );
        assert!(
            cir.fresh_validated_objects
                .iter()
                .any(|item| item.rsync_uri == "rsync://example.test/repo/a.cer")
        );
        assert!(
            !cir.fresh_validated_objects
                .iter()
                .any(|item| item.rsync_uri == cir.trust_anchors[0].ta_rsync_uri)
        );
        assert!(!cir.trust_anchors[0].ta_certificate_der.is_empty());
    }

    #[test]
    fn export_cir_from_run_writes_der_and_static_pool() {
        let td = tempfile::tempdir().unwrap();
        let store_dir = td.path().join("db");
        let out_dir = td.path().join("out");
        let _static_root = td.path().join("static");
        let store = RocksStore::open(&store_dir).unwrap();

        let bytes = b"object-b".to_vec();
        let hash = sha256_hex(&bytes);
        let mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone());
        raw.origin_uris
            .push("rsync://example.test/repo/b.roa".into());
        store.put_raw_by_hash_entry(&raw).unwrap();
        let publication_points = vec![PublicationPointAudit {
            objects: vec![audit_entry(
                "rsync://example.test/repo/b.roa",
                &hash,
                crate::audit::AuditObjectKind::Roa,
                crate::audit::AuditObjectResult::Ok,
                None,
            )],
            ..PublicationPointAudit::default()
        }];

        let ta = sample_trust_anchor();
        let cir_path = out_dir.join("example.cir");
        let summary = export_cir_from_run(
            &store,
            &ta,
            "https://example.test/root.tal",
            sample_time(),
            &publication_points,
            &cir_path,
            sample_date(),
        )
        .expect("export cir");
        assert_eq!(summary.trust_anchor_count, 1);
        assert_eq!(summary.object_count, 1);
        assert!(summary.timing.total_ms >= summary.timing.build_cir_ms);

        let der = std::fs::read(&cir_path).unwrap();
        let cir = decode_cir(&der).unwrap();
        assert_eq!(
            cir.trust_anchors[0].tal_uri,
            "https://example.test/root.tal"
        );
    }

    #[test]
    fn export_cir_from_run_uses_raw_store_backend_without_pool_export() {
        let td = tempfile::tempdir().unwrap();
        let store_dir = td.path().join("db");
        let raw_store = td.path().join("raw-store.db");
        let out_dir = td.path().join("out");
        let store = RocksStore::open_with_external_raw_store(&store_dir, &raw_store).unwrap();

        let bytes = b"object-d".to_vec();
        let hash = sha256_hex(&bytes);
        let mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone());
        raw.origin_uris
            .push("rsync://example.test/repo/d.roa".into());
        store.put_raw_by_hash_entry(&raw).unwrap();
        let publication_points = vec![PublicationPointAudit {
            objects: vec![audit_entry(
                "rsync://example.test/repo/d.roa",
                &hash,
                crate::audit::AuditObjectKind::Roa,
                crate::audit::AuditObjectResult::Ok,
                None,
            )],
            ..PublicationPointAudit::default()
        }];

        let ta = sample_trust_anchor();
        let cir_path = out_dir.join("example.cir");
        let summary = export_cir_from_run(
            &store,
            &ta,
            "https://example.test/root.tal",
            sample_time(),
            &publication_points,
            &cir_path,
            sample_date(),
        )
        .expect("export cir");
        assert_eq!(summary.object_count, 1);
        assert!(raw_store.exists());
        assert!(cir_path.exists());
    }

    #[test]
    fn export_cir_from_run_does_not_write_ta_bytes_to_repo_bytes_store() {
        let td = tempfile::tempdir().unwrap();
        let store = RocksStore::open_with_external_repo_bytes(
            &td.path().join("db"),
            &td.path().join("repo-bytes.db"),
        )
        .unwrap();

        let ta = sample_trust_anchor();
        let ta_hash = sha256_hex(&ta.ta_certificate.raw_der);
        let cir_path = td.path().join("out").join("example.cir");
        export_cir_from_run(
            &store,
            &ta,
            "https://example.test/root.tal",
            sample_time(),
            &[],
            &cir_path,
            sample_date(),
        )
        .expect("export cir");

        assert_eq!(store.get_blob_bytes(&ta_hash).unwrap(), None);
    }

    #[test]
    fn build_cir_from_run_includes_consumed_vcir_current_instance_objects_from_audit() {
        let td = tempfile::tempdir().unwrap();
        let store = RocksStore::open(td.path()).unwrap();
        let ta = sample_trust_anchor();

        let mut pp = PublicationPointAudit {
            source: "vcir_current_instance".to_string(),
            ..PublicationPointAudit::default()
        };
        pp.objects.push(crate::audit::ObjectAuditEntry {
            rsync_uri: "rsync://example.test/repo/fallback.mft".to_string(),
            sha256_hex: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
                .to_string(),
            kind: crate::audit::AuditObjectKind::Manifest,
            result: crate::audit::AuditObjectResult::Ok,
            detail: None,
        });
        pp.objects.push(crate::audit::ObjectAuditEntry {
            rsync_uri: "rsync://example.test/repo/fallback.roa".to_string(),
            sha256_hex: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
                .to_string(),
            kind: crate::audit::AuditObjectKind::Roa,
            result: crate::audit::AuditObjectResult::Ok,
            detail: None,
        });

        let cir = build_cir_from_run(
            &store,
            &ta,
            "https://example.test/root.tal",
            sample_time(),
            &[pp],
        )
        .expect("build cir");

        assert!(
            cir.cached_validated_objects
                .iter()
                .any(|item| item.rsync_uri == "rsync://example.test/repo/fallback.mft")
        );
        assert!(
            cir.cached_validated_objects
                .iter()
                .any(|item| item.rsync_uri == "rsync://example.test/repo/fallback.roa")
        );
    }

    #[test]
    fn build_cir_from_run_multi_ignores_current_repo_superfluous_objects() {
        let td = tempfile::tempdir().unwrap();
        let store = RocksStore::open(td.path()).unwrap();
        let ta1 = sample_trust_anchor();
        let ta2 = sample_arin_trust_anchor();
        let current_repo_objects = vec![crate::current_repo_index::CurrentRepoObject {
            rsync_uri: "rsync://example.test/repo/superfluous.roa".to_string(),
            current_hash_hex: "11".repeat(32),
            repository_source: "https://rrdp.example.test/notification.xml".to_string(),
            object_type: Some("roa".to_string()),
        }];
        let publication_points = vec![PublicationPointAudit {
            objects: vec![audit_entry(
                "rsync://example.test/repo/consumed.roa",
                &"22".repeat(32),
                crate::audit::AuditObjectKind::Roa,
                crate::audit::AuditObjectResult::Ok,
                None,
            )],
            ..PublicationPointAudit::default()
        }];

        let cir = build_cir_from_run_multi(
            &store,
            &[
                CirTrustAnchorBinding {
                    trust_anchor: &ta1,
                    tal_uri: "https://example.test/apnic.tal",
                },
                CirTrustAnchorBinding {
                    trust_anchor: &ta2,
                    tal_uri: "https://example.test/arin.tal",
                },
            ],
            sample_time(),
            &publication_points,
            Some(&current_repo_objects),
        )
        .expect("build cir from consumed audit objects");

        assert_eq!(cir.trust_anchors.len(), 2);
        assert!(
            cir.fresh_validated_objects
                .iter()
                .any(|item| item.rsync_uri == "rsync://example.test/repo/consumed.roa")
        );
        assert!(
            !cir.fresh_validated_objects
                .iter()
                .any(|item| item.rsync_uri == "rsync://example.test/repo/superfluous.roa"),
            "current repo objects must not be included unless validation consumed them",
        );
        for trust_anchor in &cir.trust_anchors {
            assert!(
                !cir.fresh_validated_objects
                    .iter()
                    .any(|item| item.rsync_uri == trust_anchor.ta_rsync_uri),
                "trust anchor rsync objects must not be included in CIR.objects",
            );
        }
    }

    #[test]
    fn build_cir_from_run_multi_sorts_trust_anchors_by_ta_rsync_uri() {
        let td = tempfile::tempdir().unwrap();
        let store = RocksStore::open(td.path()).unwrap();
        let apnic = sample_trust_anchor();
        let arin = sample_arin_trust_anchor();

        let cir = build_cir_from_run_multi(
            &store,
            &[
                CirTrustAnchorBinding {
                    trust_anchor: &apnic,
                    tal_uri: "https://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer",
                },
                CirTrustAnchorBinding {
                    trust_anchor: &arin,
                    tal_uri: "https://rrdp.arin.net/arin-rpki-ta.cer",
                },
            ],
            sample_time(),
            &[],
            Some(&[]),
        )
        .expect("build cir with unsorted input bindings");

        assert_eq!(
            cir.trust_anchors
                .iter()
                .map(|trust_anchor| trust_anchor.ta_rsync_uri.as_str())
                .collect::<Vec<_>>(),
            vec![
                "rsync://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer",
                "rsync://rpki.arin.net/repository/arin-rpki-ta.cer",
            ]
        );
    }

    #[test]
    fn build_cir_from_run_multi_exports_rejected_objects_from_error_audit_only() {
        let td = tempfile::tempdir().unwrap();
        let store = RocksStore::open(td.path()).unwrap();
        let ta = sample_trust_anchor();
        let publication_points = vec![PublicationPointAudit {
            objects: vec![
                crate::audit::ObjectAuditEntry {
                    rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
                    sha256_hex: "11".repeat(32),
                    kind: crate::audit::AuditObjectKind::Roa,
                    result: crate::audit::AuditObjectResult::Error,
                    detail: Some("invalid roa".to_string()),
                },
                crate::audit::ObjectAuditEntry {
                    rsync_uri: "rsync://example.test/repo/b.asa".to_string(),
                    sha256_hex: "22".repeat(32),
                    kind: crate::audit::AuditObjectKind::Aspa,
                    result: crate::audit::AuditObjectResult::Skipped,
                    detail: Some("skipped".to_string()),
                },
                crate::audit::ObjectAuditEntry {
                    rsync_uri: "rsync://example.test/repo/c.roa".to_string(),
                    sha256_hex: "33".repeat(32),
                    kind: crate::audit::AuditObjectKind::Roa,
                    result: crate::audit::AuditObjectResult::Error,
                    detail: Some("second rejected roa".to_string()),
                },
            ],
            ..PublicationPointAudit::default()
        }];

        let cir = build_cir_from_run_multi(
            &store,
            &[CirTrustAnchorBinding {
                trust_anchor: &ta,
                tal_uri: "https://example.test/root.tal",
            }],
            sample_time(),
            &publication_points,
            None,
        )
        .expect("build cir");

        assert_eq!(cir.rejected_object_count(), 2);
        assert_eq!(
            cir.fresh_rejected_objects[0].object_uri,
            "rsync://example.test/repo/a.roa"
        );
        assert_eq!(
            cir.fresh_rejected_objects[0].reason.as_deref(),
            Some("invalid roa")
        );
        assert_eq!(
            cir.fresh_rejected_objects[1].object_uri,
            "rsync://example.test/repo/c.roa"
        );
        assert!(
            cir.fresh_validated_objects
                .iter()
                .any(|item| item.rsync_uri == "rsync://example.test/repo/a.roa"),
            "rejected audit objects were still consumed as validation input",
        );
        assert!(
            cir.fresh_validated_objects
                .iter()
                .any(|item| item.rsync_uri == "rsync://example.test/repo/c.roa"),
            "rejected audit objects were still consumed as validation input",
        );
        assert!(
            !cir.fresh_validated_objects
                .iter()
                .any(|item| item.rsync_uri == "rsync://example.test/repo/b.asa"),
            "skipped audit objects are not considered consumed input",
        );
    }

    #[test]
    fn build_cir_from_run_multi_records_crl_expired_manifest_in_objects_and_rejects() {
        let td = tempfile::tempdir().unwrap();
        let store = RocksStore::open(td.path()).unwrap();
        let ta = sample_trust_anchor();
        let manifest_uri = "rsync://example.test/repo/expired-crl.mft";
        let reject_reason = "manifest embedded EE certificate path validation failed: CRL not valid at validation_time (RFC 5280 §6.3.3(g); RFC 5280 §5.1.2.4-§5.1.2.5; RFC 6487 §5)";
        let publication_points = vec![PublicationPointAudit {
            objects: vec![crate::audit::ObjectAuditEntry {
                rsync_uri: manifest_uri.to_string(),
                sha256_hex: "44".repeat(32),
                kind: crate::audit::AuditObjectKind::Manifest,
                result: crate::audit::AuditObjectResult::Error,
                detail: Some(reject_reason.to_string()),
            }],
            ..PublicationPointAudit::default()
        }];

        let cir = build_cir_from_run_multi(
            &store,
            &[CirTrustAnchorBinding {
                trust_anchor: &ta,
                tal_uri: "https://example.test/root.tal",
            }],
            sample_time(),
            &publication_points,
            None,
        )
        .expect("build cir");

        assert!(
            cir.fresh_validated_objects
                .iter()
                .any(|item| item.rsync_uri == manifest_uri),
            "manifest rejected because issuer CRL is expired was still read as validation input",
        );
        assert_eq!(cir.rejected_object_count(), 1);
        assert_eq!(cir.fresh_rejected_objects[0].object_uri, manifest_uri);
        assert_eq!(
            cir.fresh_rejected_objects[0].reason.as_deref(),
            Some(reject_reason)
        );
        assert_eq!(
            cir.reject_list_sha256,
            crate::cir::model::compute_reject_list_sha256([manifest_uri].into_iter())
        );
    }

    #[test]
    fn build_cir_from_run_multi_excludes_manifest_locked_files_when_manifest_is_rejected() {
        let td = tempfile::tempdir().unwrap();
        let store = RocksStore::open(td.path()).unwrap();
        let ta = sample_trust_anchor();
        let manifest_uri = "rsync://example.test/repo/rejected.mft";
        let roa_uri = "rsync://example.test/repo/listed.roa";
        let crl_uri = "rsync://example.test/repo/listed.crl";
        let publication_points = vec![PublicationPointAudit {
            objects: vec![
                crate::audit::ObjectAuditEntry {
                    rsync_uri: manifest_uri.to_string(),
                    sha256_hex: "44".repeat(32),
                    kind: crate::audit::AuditObjectKind::Manifest,
                    result: crate::audit::AuditObjectResult::Error,
                    detail: Some("manifest EE cert path rejected".to_string()),
                },
                crate::audit::ObjectAuditEntry {
                    rsync_uri: roa_uri.to_string(),
                    sha256_hex: "55".repeat(32),
                    kind: crate::audit::AuditObjectKind::Roa,
                    result: crate::audit::AuditObjectResult::Skipped,
                    detail: Some("manifest rejected before locked object validation".to_string()),
                },
                crate::audit::ObjectAuditEntry {
                    rsync_uri: crl_uri.to_string(),
                    sha256_hex: "66".repeat(32),
                    kind: crate::audit::AuditObjectKind::Crl,
                    result: crate::audit::AuditObjectResult::Skipped,
                    detail: Some("manifest rejected before locked object validation".to_string()),
                },
            ],
            ..PublicationPointAudit::default()
        }];

        let cir = build_cir_from_run_multi(
            &store,
            &[CirTrustAnchorBinding {
                trust_anchor: &ta,
                tal_uri: "https://example.test/root.tal",
            }],
            sample_time(),
            &publication_points,
            None,
        )
        .expect("build cir");

        assert!(
            cir.fresh_validated_objects
                .iter()
                .any(|item| item.rsync_uri == manifest_uri),
            "rejected manifest is still a current-run validation input",
        );
        assert!(
            !cir.fresh_validated_objects
                .iter()
                .any(|item| item.rsync_uri == roa_uri),
            "ROA listed by a rejected manifest must not enter CIR objects",
        );
        assert!(
            !cir.fresh_validated_objects
                .iter()
                .any(|item| item.rsync_uri == crl_uri),
            "CRL listed by a rejected manifest must not enter CIR objects",
        );
        assert_eq!(cir.rejected_object_count(), 1);
        assert_eq!(cir.fresh_rejected_objects[0].object_uri, manifest_uri);
    }

    #[test]
    fn build_cir_from_run_multi_reject_digest_ignores_reason_text() {
        let td = tempfile::tempdir().unwrap();
        let store = RocksStore::open(td.path()).unwrap();
        let ta = sample_trust_anchor();

        let mk_pp = |detail: &str| PublicationPointAudit {
            objects: vec![crate::audit::ObjectAuditEntry {
                rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
                sha256_hex: "11".repeat(32),
                kind: crate::audit::AuditObjectKind::Roa,
                result: crate::audit::AuditObjectResult::Error,
                detail: Some(detail.to_string()),
            }],
            ..PublicationPointAudit::default()
        };

        let cir_a = build_cir_from_run_multi(
            &store,
            &[CirTrustAnchorBinding {
                trust_anchor: &ta,
                tal_uri: "https://example.test/root.tal",
            }],
            sample_time(),
            &[mk_pp("reason-a")],
            None,
        )
        .expect("build cir a");
        let cir_b = build_cir_from_run_multi(
            &store,
            &[CirTrustAnchorBinding {
                trust_anchor: &ta,
                tal_uri: "https://example.test/root.tal",
            }],
            sample_time(),
            &[mk_pp("reason-b")],
            None,
        )
        .expect("build cir b");

        assert_eq!(cir_a.reject_list_sha256, cir_b.reject_list_sha256);
        assert_ne!(
            cir_a.fresh_rejected_objects[0].reason,
            cir_b.fresh_rejected_objects[0].reason
        );
    }

    #[test]
    fn build_cir_from_run_multi_rejects_invalid_tal_uri_and_missing_rsync_ta_uri() {
        let td = tempfile::tempdir().unwrap();
        let store = RocksStore::open(td.path()).unwrap();

        let err = build_cir_from_run_multi(
            &store,
            &[CirTrustAnchorBinding {
                trust_anchor: &sample_trust_anchor(),
                tal_uri: "file:///not-supported.tal",
            }],
            sample_time(),
            &[],
            None,
        )
        .expect_err("non-http tal uri must fail");
        assert!(matches!(err, CirExportError::InvalidTalUri(_)), "{err}");

        let err = build_cir_from_run_multi(
            &store,
            &[CirTrustAnchorBinding {
                trust_anchor: &sample_trust_anchor_without_rsync_uri(),
                tal_uri: "https://example.test/root.tal",
            }],
            sample_time(),
            &[],
            None,
        )
        .expect_err("missing rsync ta uri must fail");
        assert!(matches!(err, CirExportError::MissingTaRsyncUri), "{err}");
    }

    #[test]
    fn build_cir_from_input_snapshot_multi_preserves_online_sections() {
        let ta = sample_trust_anchor();
        let cir = build_cir_from_input_snapshot_multi(
            &[CirTrustAnchorBinding {
                trust_anchor: &ta,
                tal_uri: "https://example.test/root.tal",
            }],
            sample_time(),
            CirInputSnapshot {
                fresh_validated_objects: vec![CirObject {
                    rsync_uri: "rsync://example.test/repo/fresh.roa".to_string(),
                    sha256: vec![0x11; 32],
                }],
                cached_validated_objects: vec![CirObject {
                    rsync_uri: "rsync://example.test/repo/cached.roa".to_string(),
                    sha256: vec![0x22; 32],
                }],
                fresh_rejected_objects: vec![CirRejectedObject {
                    object_uri: "rsync://example.test/repo/fresh.roa".to_string(),
                    reason: Some("fresh rejected".to_string()),
                }],
                cached_rejected_objects: vec![CirRejectedObject {
                    object_uri: "rsync://example.test/repo/cached.roa".to_string(),
                    reason: None,
                }],
            },
        )
        .expect("build CIR from online input");

        cir.validate().expect("online CIR snapshot is valid");
        assert_eq!(cir.fresh_validated_objects.len(), 1);
        assert_eq!(cir.cached_validated_objects.len(), 1);
        assert_eq!(cir.fresh_rejected_objects.len(), 1);
        assert_eq!(cir.cached_rejected_objects.len(), 1);
    }

    #[test]
    fn export_cir_static_pool_writes_repository_objects_only() {
        let td = tempfile::tempdir().unwrap();
        let store = RocksStore::open(&td.path().join("db")).unwrap();
        let static_root = td.path().join("static");
        let ta1 = sample_trust_anchor();
        let ta2 = sample_arin_trust_anchor();

        let object_bytes = b"object-z".to_vec();
        let hash = sha256_hex(&object_bytes);
        let mut raw = RawByHashEntry::from_bytes(hash.clone(), object_bytes.clone());
        raw.origin_uris
            .push("rsync://example.test/repo/z.roa".into());
        store.put_raw_by_hash_entry(&raw).unwrap();
        let publication_points = vec![PublicationPointAudit {
            objects: vec![audit_entry(
                "rsync://example.test/repo/z.roa",
                &hash,
                crate::audit::AuditObjectKind::Roa,
                crate::audit::AuditObjectResult::Ok,
                None,
            )],
            ..PublicationPointAudit::default()
        }];

        let cir = build_cir_from_run_multi(
            &store,
            &[
                CirTrustAnchorBinding {
                    trust_anchor: &ta1,
                    tal_uri: "https://example.test/apnic.tal",
                },
                CirTrustAnchorBinding {
                    trust_anchor: &ta2,
                    tal_uri: "https://example.test/arin.tal",
                },
            ],
            sample_time(),
            &publication_points,
            None,
        )
        .expect("build cir");

        let summary =
            export_cir_static_pool(&store, &static_root, sample_date(), &cir, &[&ta1, &ta2])
                .expect("export static pool");
        assert_eq!(summary.unique_hashes, 1);
        assert_eq!(summary.written_files, 1);
        for trust_anchor in &cir.trust_anchors {
            let ta_hash = hex::encode(&trust_anchor.ta_certificate_sha256);
            assert!(
                !crate::cir::static_pool::static_pool_path(&static_root, sample_date(), &ta_hash)
                    .expect("static pool ta path")
                    .exists()
            );
        }
    }

    #[test]
    fn export_cir_raw_store_reports_missing_non_ta_object_only() {
        let td = tempfile::tempdir().unwrap();
        let raw_store_path = td.path().join("raw-store.db");
        let store =
            RocksStore::open_with_external_raw_store(&td.path().join("db"), &raw_store_path)
                .unwrap();
        let ta1 = sample_trust_anchor();
        let ta2 = sample_arin_trust_anchor();

        let cir_only_tas = build_cir_from_run_multi(
            &store,
            &[
                CirTrustAnchorBinding {
                    trust_anchor: &ta1,
                    tal_uri: "https://example.test/apnic.tal",
                },
                CirTrustAnchorBinding {
                    trust_anchor: &ta2,
                    tal_uri: "https://example.test/arin.tal",
                },
            ],
            sample_time(),
            &[],
            Some(&[]),
        )
        .expect("build cir with tas only");

        let summary = export_cir_raw_store(&store, &raw_store_path, &cir_only_tas, &[&ta1, &ta2])
            .expect("export raw store");
        assert_eq!(summary.unique_hashes, 0);
        assert_eq!(summary.written_entries, 0);
        assert_eq!(summary.reused_entries, 0);

        let mut cir_missing_object = cir_only_tas.clone();
        cir_missing_object.fresh_validated_objects.push(CirObject {
            rsync_uri: "rsync://example.test/repo/missing.roa".to_string(),
            sha256: vec![0x44; 32],
        });
        let err = export_cir_raw_store(&store, &raw_store_path, &cir_missing_object, &[&ta1, &ta2])
            .expect_err("missing non-ta object must fail");
        assert!(matches!(err, CirExportError::Write(_, _)), "{err}");
    }
}
