//! Crypto signature verification cache (feature #126).
//!
//! Caches the context-free cryptographic fact "this signature over these exact bytes verifies
//! under this public key" so that later runs can skip the RSA verification entirely. Only
//! positive conclusions are stored (no negative caching). The cache never replaces any other
//! validation semantics: revocation (CRL), time-window, and policy checks all run unchanged on
//! the hit path — the hook wraps exactly the crypto verify call and nothing else.
//!
//! Modes (CLI):
//!
//! - `--crypto-signature-cache-observe-only` (M1): compute keys, maintain the persistent set,
//!   record hit/miss/timing statistics, always run the real verification.
//! - `--enable-crypto-signature-cache` (M2): on a cache hit, skip the real verification and
//!   return success; on a miss, run the real verification and store the positive conclusion.
//!   Statistics are recorded the same way; `verify_skipped`/`verify_executed` report what
//!   actually happened. Both flags may be combined (reuse + statistics).
//!
//! The five choke points (see the development plan for the full caller inventory):
//!
//! - `CmsSignedObject`: `data_model::signed_object` CMS verify (ROA / ASPA / manifest on the
//!   main validation path; GBR objects are not signature-verified by the tree runner).
//! - `EeCertFast`: `validation::cert_path::verify_ee_cert_signature_fast` (ring, TBS + signature
//!   bytes from the signed-object embedded EE certificate; ROA / ASPA / manifest EE paths).
//! - `EeCertX509`: `validation::cert_path::verify_ee_cert_signature` (x509-parser; router
//!   certificates and the `validate_ee_cert_path` family).
//! - `ChildCaCert`: `validation::ca_path::verify_child_signature` (x509-parser; subordinate CA
//!   certificates).
//! - `Crl`: `data_model::crl::verify_signature_with_issuer_spki` (x509-parser; issuer CRLs).
//!
//! Trust-anchor self-signature verification is deliberately excluded (a handful of calls per
//! run).
//!
//! Cache key (M1 decision: verify-input bytes, not full object bytes — the key material is in
//! hand inside each choke point, while the repo-bytes content hash is only available at the
//! upper processing layer):
//!
//! ```text
//! key = SHA-256( "rpki-crypto-sig-cache-key-v1" | point-tag | "sha256WithRSAEncryption"
//!                | SHA-256(verify input bytes) | SHA-256(signature bytes)
//!                | SHA-256(verifier SPKI DER) )
//! ```
//!
//! The signature bytes are part of the key so that a different signature over the same input
//! under the same key can never inherit a positive verification fact.
//!
//! Storage:
//!
//! - Single small file next to the work DB (`<work-db-name>.crypto-sig-cache`), never inside
//!   the work DB, not touched by periodic snapshot reset (#091/#092): the reset paths only
//!   remove/replace the work DB directory and the PP/child-cert cache artifacts, not siblings
//!   of their own choosing.
//! - Binary format: 8-byte magic `RPKICSC2` | u64 record-count | records of
//!   key[32] | verified_at_unix_secs(i64) | crypto_impl_version(u32) | reserved(u32).
//!   A missing, corrupt, or magic-mismatched file (including the M1 `RPKICSC1` seen-set
//!   format) is silently rebuilt empty. Writes are atomic (write-then-rename).
//! - The cache is persisted once at the end of the validation stage (same point as the M1
//!   seen-set). If a run fails mid-validation, entries added during that run are lost;
//!   previously persisted entries are unaffected.
//! - Size cap: approximate上限 `capacity` entries (default 2,000,000; measured M1 APNIC
//!   ~148k keys/run, all5 estimated ~750k). When an insert would exceed the cap, the whole
//!   cache is cleared and rebuilt from scratch (full-rebuild eviction — the simplest policy;
//!   concurrent inserts may briefly overshoot the cap by a few entries). Each eviction is
//!   counted in `evictions_total`.
//! - Concurrency: entries live in 64 shards keyed by the first key byte, so parallel
//!   validation workers rarely contend; statistics use relaxed atomics.

use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, RwLock};

use serde::Serialize;
use sha2::Digest;

const CACHE_FILE_MAGIC: &[u8; 8] = b"RPKICSC2";
const KEY_DOMAIN: &[u8] = b"rpki-crypto-sig-cache-key-v1";
const ALG_ID: &[u8] = b"sha256WithRSAEncryption";
const SHARD_COUNT: usize = 64;
const RECORD_BYTES: usize = 32 + 8 + 4 + 4;

/// The schema version reported in summaries. The on-disk magic already carries the format
/// version; this constant is the semantic version of the key/value definitions.
pub const CACHE_SCHEMA_VERSION: u32 = 2;

/// Default maximum number of cached positive conclusions (see module docs).
pub const DEFAULT_CAPACITY: usize = 2_000_000;

/// Crypto implementation identifier stored in every value. Bump when the verification
/// backend changes so old entries can be invalidated wholesale.
/// `1` = ring 0.17 `RSA_PKCS1_2048_8192_SHA256` (direct ring calls and x509-parser's
/// `verify` feature, which is backed by ring in this crate).
pub const CRYPTO_IMPL_VERSION: u32 = 1;

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CryptoSigVerifyPoint {
    CmsSignedObject,
    EeCertFast,
    EeCertX509,
    ChildCaCert,
    Crl,
}

impl CryptoSigVerifyPoint {
    pub const ALL: [Self; 5] = [
        Self::CmsSignedObject,
        Self::EeCertFast,
        Self::EeCertX509,
        Self::ChildCaCert,
        Self::Crl,
    ];

    fn tag(self) -> u8 {
        match self {
            Self::CmsSignedObject => 1,
            Self::EeCertFast => 2,
            Self::EeCertX509 => 3,
            Self::ChildCaCert => 4,
            Self::Crl => 5,
        }
    }

    pub fn name(self) -> &'static str {
        match self {
            Self::CmsSignedObject => "cms_signed_object",
            Self::EeCertFast => "ee_cert_fast",
            Self::EeCertX509 => "ee_cert_x509",
            Self::ChildCaCert => "child_ca_cert",
            Self::Crl => "crl",
        }
    }

    fn from_index(index: usize) -> Self {
        Self::ALL[index]
    }
}

/// Compute the cache key for one verification fact.
///
/// `input_bytes` is the exact byte string fed to the verifier (CMS signedAttrs DER for
/// signature, TBSCertificate DER, TBSCertList DER), `signature_bytes` the signature value,
/// and `verifier_spki_der` the DER of the verifying SubjectPublicKeyInfo.
pub fn compute_cache_key(
    point: CryptoSigVerifyPoint,
    input_bytes: &[u8],
    signature_bytes: &[u8],
    verifier_spki_der: &[u8],
) -> [u8; 32] {
    let mut h = sha2::Sha256::new();
    h.update(KEY_DOMAIN);
    h.update([point.tag()]);
    h.update(ALG_ID);
    for part in [input_bytes, signature_bytes, verifier_spki_der] {
        let part_hash = sha2::Sha256::digest(part);
        h.update(part_hash);
    }
    h.finalize().into()
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CacheValue {
    pub verified_at_unix_secs: i64,
    pub crypto_impl_version: u32,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct CryptoSigVerifyPointStats {
    pub calls: u64,
    /// Key already present in the cache (positive conclusion available).
    pub would_hit: u64,
    /// Key not present; after a successful verification it is inserted.
    pub new_keys: u64,
    /// Real cryptographic verification executed (always equals `calls` in observe-only
    /// mode; equals `new_keys` plus hit-but-observe calls otherwise).
    pub verify_executed: u64,
    /// Real verification skipped because of a cache hit (only > 0 when reuse is enabled).
    pub verify_skipped: u64,
    pub verify_nanos_on_would_hit: u64,
    pub verify_nanos_on_new_key: u64,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct CryptoSigCacheSummary {
    pub schema_version: u32,
    pub cache_file: String,
    pub reuse_enabled: bool,
    pub capacity: u64,
    pub entries_loaded: u64,
    pub entries_total: u64,
    pub evictions_total: u64,
    pub per_point: BTreeMap<String, CryptoSigVerifyPointStats>,
}

#[derive(Default)]
struct PointCounters {
    calls: AtomicU64,
    would_hit: AtomicU64,
    new_keys: AtomicU64,
    verify_executed: AtomicU64,
    verify_skipped: AtomicU64,
    verify_nanos_on_would_hit: AtomicU64,
    verify_nanos_on_new_key: AtomicU64,
}

fn shard_index(key: &[u8; 32]) -> usize {
    (key[0] as usize) % SHARD_COUNT
}

/// The persistent positive-conclusion signature cache plus per-run statistics.
///
/// See the module docs for storage format, eviction, and failure semantics.
pub struct CryptoSigCache {
    cache_file: PathBuf,
    reuse_enabled: bool,
    capacity: usize,
    entries_loaded: u64,
    shards: Vec<Mutex<HashMap<[u8; 32], CacheValue>>>,
    len: AtomicUsize,
    evictions: AtomicU64,
    eviction_lock: Mutex<()>,
    points: [PointCounters; 5],
}

impl CryptoSigCache {
    /// Load the cache from `cache_file`; start empty when the file is missing, corrupt, or
    /// has an unknown schema header. `reuse_enabled` selects the M2 hit-skips-verify
    /// behavior; when false the cache is observe-only (M1 semantics).
    pub fn load_or_rebuild(cache_file: PathBuf, reuse_enabled: bool) -> Self {
        Self::load_or_rebuild_with_capacity(cache_file, reuse_enabled, DEFAULT_CAPACITY)
    }

    pub fn load_or_rebuild_with_capacity(
        cache_file: PathBuf,
        reuse_enabled: bool,
        capacity: usize,
    ) -> Self {
        let entries = load_cache_file(&cache_file).unwrap_or_default();
        let mut shards = Vec::with_capacity(SHARD_COUNT);
        for _ in 0..SHARD_COUNT {
            shards.push(Mutex::new(HashMap::new()));
        }
        let mut len = 0usize;
        for (key, value) in entries {
            // Respect the cap even when loading a foreign/oversized file: keep the first
            // `capacity` records in file order.
            if len >= capacity {
                break;
            }
            shards[shard_index(&key)]
                .lock()
                .expect("crypto sig shard lock")
                .insert(key, value);
            len += 1;
        }
        Self {
            cache_file,
            reuse_enabled,
            capacity,
            entries_loaded: len as u64,
            shards,
            len: AtomicUsize::new(len),
            evictions: AtomicU64::new(0),
            eviction_lock: Mutex::new(()),
            points: Default::default(),
        }
    }

    pub fn reuse_enabled(&self) -> bool {
        self.reuse_enabled
    }

    fn lookup(&self, key: &[u8; 32]) -> bool {
        self.shards[shard_index(key)]
            .lock()
            .expect("crypto sig shard lock")
            .contains_key(key)
    }

    #[cfg(test)]
    pub fn contains(&self, key: &[u8; 32]) -> bool {
        self.lookup(key)
    }

    /// Insert a positive conclusion (used by the verify path and by tests).
    pub fn insert_positive(&self, key: [u8; 32]) {
        let value = CacheValue {
            verified_at_unix_secs: time::OffsetDateTime::now_utc().unix_timestamp(),
            crypto_impl_version: CRYPTO_IMPL_VERSION,
        };
        if self.len.load(Ordering::Relaxed) >= self.capacity {
            self.evict_all();
        }
        let mut shard = self.shards[shard_index(&key)]
            .lock()
            .expect("crypto sig shard lock");
        if shard.insert(key, value).is_none() {
            self.len.fetch_add(1, Ordering::Relaxed);
        }
    }

    /// Full-rebuild eviction: clear every shard. See the module docs for the semantics.
    fn evict_all(&self) {
        let _guard = self.eviction_lock.lock().expect("crypto sig eviction lock");
        if self.len.load(Ordering::Relaxed) < self.capacity {
            // Another thread already evicted.
            return;
        }
        for shard in &self.shards {
            shard.lock().expect("crypto sig shard lock").clear();
        }
        self.len.store(0, Ordering::Relaxed);
        self.evictions.fetch_add(1, Ordering::Relaxed);
    }

    /// Cache-aware verification: observe-only or reuse depending on `reuse_enabled`.
    /// The `verify` closure must perform exactly the real cryptographic verification.
    pub fn verify<E>(
        &self,
        point: CryptoSigVerifyPoint,
        input_bytes: &[u8],
        signature_bytes: &[u8],
        verifier_spki_der: &[u8],
        verify: impl FnOnce() -> Result<(), E>,
    ) -> Result<(), E> {
        let key = compute_cache_key(point, input_bytes, signature_bytes, verifier_spki_der);
        let hit = self.lookup(&key);
        let counters = &self.points[(point.tag() - 1) as usize];
        counters.calls.fetch_add(1, Ordering::Relaxed);
        if hit {
            counters.would_hit.fetch_add(1, Ordering::Relaxed);
            if self.reuse_enabled {
                counters.verify_skipped.fetch_add(1, Ordering::Relaxed);
                return Ok(());
            }
        } else {
            counters.new_keys.fetch_add(1, Ordering::Relaxed);
        }
        counters.verify_executed.fetch_add(1, Ordering::Relaxed);
        let started = std::time::Instant::now();
        let result = verify();
        let nanos = started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64;
        if hit {
            counters
                .verify_nanos_on_would_hit
                .fetch_add(nanos, Ordering::Relaxed);
        } else {
            counters
                .verify_nanos_on_new_key
                .fetch_add(nanos, Ordering::Relaxed);
            if result.is_ok() {
                self.insert_positive(key);
            }
        }
        result
    }

    /// Persist the cache atomically (write-then-rename).
    pub fn persist(&self) -> Result<(), String> {
        persist_cache_file(&self.cache_file, &self.shards)
    }

    pub fn summary(&self) -> CryptoSigCacheSummary {
        let mut per_point = BTreeMap::new();
        for (index, counters) in self.points.iter().enumerate() {
            let stats = CryptoSigVerifyPointStats {
                calls: counters.calls.load(Ordering::Relaxed),
                would_hit: counters.would_hit.load(Ordering::Relaxed),
                new_keys: counters.new_keys.load(Ordering::Relaxed),
                verify_executed: counters.verify_executed.load(Ordering::Relaxed),
                verify_skipped: counters.verify_skipped.load(Ordering::Relaxed),
                verify_nanos_on_would_hit: counters
                    .verify_nanos_on_would_hit
                    .load(Ordering::Relaxed),
                verify_nanos_on_new_key: counters
                    .verify_nanos_on_new_key
                    .load(Ordering::Relaxed),
            };
            if stats.calls > 0 {
                per_point.insert(
                    CryptoSigVerifyPoint::from_index(index).name().to_string(),
                    stats,
                );
            }
        }
        CryptoSigCacheSummary {
            schema_version: CACHE_SCHEMA_VERSION,
            cache_file: self.cache_file.to_string_lossy().into_owned(),
            reuse_enabled: self.reuse_enabled,
            capacity: self.capacity as u64,
            entries_loaded: self.entries_loaded,
            entries_total: self.len.load(Ordering::Relaxed) as u64,
            evictions_total: self.evictions.load(Ordering::Relaxed),
            per_point,
        }
    }
}

/// Default cache file location: a sibling of the work DB directory, mirroring
/// `default_pp_cache_index_dir` (`<work-db-name>.crypto-sig-cache`).
pub fn default_cache_file_path(db_path: &Path) -> PathBuf {
    let file_name = db_path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("work-db");
    db_path.with_file_name(format!("{file_name}.crypto-sig-cache"))
}

fn load_cache_file(path: &Path) -> Option<HashMap<[u8; 32], CacheValue>> {
    let bytes = std::fs::read(path).ok()?;
    let header_len = CACHE_FILE_MAGIC.len() + 8;
    if bytes.len() < header_len || &bytes[..CACHE_FILE_MAGIC.len()] != CACHE_FILE_MAGIC {
        return None;
    }
    let mut count_bytes = [0u8; 8];
    count_bytes.copy_from_slice(&bytes[CACHE_FILE_MAGIC.len()..header_len]);
    let count = u64::from_le_bytes(count_bytes) as usize;
    let body = &bytes[header_len..];
    if body.len() != count.saturating_mul(RECORD_BYTES) {
        return None;
    }
    let mut entries = HashMap::with_capacity(count);
    for chunk in body.chunks_exact(RECORD_BYTES) {
        let mut key = [0u8; 32];
        key.copy_from_slice(&chunk[..32]);
        let mut secs_bytes = [0u8; 8];
        secs_bytes.copy_from_slice(&chunk[32..40]);
        let mut version_bytes = [0u8; 4];
        version_bytes.copy_from_slice(&chunk[40..44]);
        entries.insert(
            key,
            CacheValue {
                verified_at_unix_secs: i64::from_le_bytes(secs_bytes),
                crypto_impl_version: u32::from_le_bytes(version_bytes),
            },
        );
    }
    Some(entries)
}

fn persist_cache_file(
    path: &Path,
    shards: &[Mutex<HashMap<[u8; 32], CacheValue>>],
) -> Result<(), String> {
    let mut bytes = Vec::new();
    bytes.extend_from_slice(CACHE_FILE_MAGIC);
    // Placeholder for the record count; patched after collecting.
    bytes.extend_from_slice(&[0u8; 8]);
    let mut count = 0u64;
    for shard in shards {
        let map = shard.lock().expect("crypto sig shard lock");
        for (key, value) in map.iter() {
            bytes.extend_from_slice(key);
            bytes.extend_from_slice(&value.verified_at_unix_secs.to_le_bytes());
            bytes.extend_from_slice(&value.crypto_impl_version.to_le_bytes());
            bytes.extend_from_slice(&[0u8; 4]);
            count += 1;
        }
    }
    bytes[CACHE_FILE_MAGIC.len()..CACHE_FILE_MAGIC.len() + 8]
        .copy_from_slice(&count.to_le_bytes());
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| format!("create cache-file dir failed: {}: {e}", parent.display()))?;
    }
    let tmp_path = path.with_extension("tmp");
    std::fs::write(&tmp_path, &bytes)
        .map_err(|e| format!("write cache file failed: {}: {e}", tmp_path.display()))?;
    std::fs::rename(&tmp_path, path).map_err(|e| {
        format!(
            "rename cache file failed: {} -> {}: {e}",
            tmp_path.display(),
            path.display()
        )
    })?;
    Ok(())
}

static GLOBAL_CACHE: RwLock<Option<Arc<CryptoSigCache>>> = RwLock::new(None);

/// Install the process-global cache (called once from the CLI run path when
/// `--crypto-signature-cache-observe-only` and/or `--enable-crypto-signature-cache` is set).
pub fn install_global(cache: Arc<CryptoSigCache>) {
    *GLOBAL_CACHE.write().expect("crypto sig cache lock") = Some(cache);
}

/// Remove the process-global cache. Used by tests and by the CLI after the run finished.
pub fn clear_global() {
    *GLOBAL_CACHE.write().expect("crypto sig cache lock") = None;
}

pub fn global_cache() -> Option<Arc<CryptoSigCache>> {
    GLOBAL_CACHE
        .read()
        .expect("crypto sig cache lock")
        .clone()
}

/// Choke-point hook: cache-aware wrapper around a real signature verification.
///
/// With no cache installed this is a single read-lock check plus the verification itself;
/// verification semantics never change either way (a hit only skips the crypto primitive,
/// never the surrounding revocation/time/policy checks).
pub fn verify_with_cache<E>(
    point: CryptoSigVerifyPoint,
    input_bytes: &[u8],
    signature_bytes: &[u8],
    verifier_spki_der: &[u8],
    verify: impl FnOnce() -> Result<(), E>,
) -> Result<(), E> {
    let Some(cache) = global_cache() else {
        return verify();
    };
    cache.verify(
        point,
        input_bytes,
        signature_bytes,
        verifier_spki_der,
        verify,
    )
}

#[cfg(test)]
pub(crate) static GLOBAL_TEST_LOCK: Mutex<()> = Mutex::new(());

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

    fn temp_cache(reuse_enabled: bool) -> (tempfile::TempDir, CryptoSigCache) {
        let dir = tempfile::tempdir().expect("tempdir");
        let cache = CryptoSigCache::load_or_rebuild(
            dir.path().join("work-db.crypto-sig-cache"),
            reuse_enabled,
        );
        (dir, cache)
    }

    #[test]
    fn cache_key_is_deterministic_and_domain_separated() {
        let a = compute_cache_key(
            CryptoSigVerifyPoint::CmsSignedObject,
            b"input",
            b"sig",
            b"spki",
        );
        let b = compute_cache_key(
            CryptoSigVerifyPoint::CmsSignedObject,
            b"input",
            b"sig",
            b"spki",
        );
        assert_eq!(a, b);

        // Different verify point.
        assert_ne!(
            a,
            compute_cache_key(CryptoSigVerifyPoint::Crl, b"input", b"sig", b"spki")
        );
        // Different input bytes.
        assert_ne!(
            a,
            compute_cache_key(
                CryptoSigVerifyPoint::CmsSignedObject,
                b"input2",
                b"sig",
                b"spki"
            )
        );
        // Different signature bytes over the same input must not share a key.
        assert_ne!(
            a,
            compute_cache_key(
                CryptoSigVerifyPoint::CmsSignedObject,
                b"input",
                b"sig2",
                b"spki"
            )
        );
        // Different verifier key.
        assert_ne!(
            a,
            compute_cache_key(
                CryptoSigVerifyPoint::CmsSignedObject,
                b"input",
                b"sig",
                b"spki2"
            )
        );
    }

    #[test]
    fn miss_verifies_and_writes_then_hit_skips_verify() {
        let (_dir, cache) = temp_cache(true);
        let mut executed = 0u64;
        // First call: miss -> real verification runs and passes -> entry written.
        cache
            .verify(
                CryptoSigVerifyPoint::EeCertFast,
                b"tbs",
                b"sig",
                b"spki",
                || -> Result<(), String> {
                    executed += 1;
                    Ok(())
                },
            )
            .expect("verify ok");
        assert_eq!(executed, 1);
        // Second call: hit -> verification skipped entirely.
        cache
            .verify(
                CryptoSigVerifyPoint::EeCertFast,
                b"tbs",
                b"sig",
                b"spki",
                || -> Result<(), String> {
                    executed += 1;
                    Ok(())
                },
            )
            .expect("verify ok");
        assert_eq!(executed, 1, "cache hit must skip the real verification");
        let summary = cache.summary();
        let stats = summary.per_point.get("ee_cert_fast").expect("stats present");
        assert_eq!(stats.calls, 2);
        assert_eq!(stats.new_keys, 1);
        assert_eq!(stats.would_hit, 1);
        assert_eq!(stats.verify_executed, 1);
        assert_eq!(stats.verify_skipped, 1);
        assert!(summary.reuse_enabled);
    }

    #[test]
    fn preset_positive_key_skips_verify_that_would_fail() {
        let (_dir, cache) = temp_cache(true);
        // Tampered object bytes whose signature can never verify; pre-seed the positive
        // conclusion directly (as if a previous run had verified the untampered object).
        let key = compute_cache_key(
            CryptoSigVerifyPoint::CmsSignedObject,
            b"tampered-input",
            b"tampered-sig",
            b"spki",
        );
        cache.insert_positive(key);
        let mut executed = 0u64;
        let result = cache.verify(
            CryptoSigVerifyPoint::CmsSignedObject,
            b"tampered-input",
            b"tampered-sig",
            b"spki",
            || -> Result<(), String> {
                executed += 1;
                Err("signature does not verify".to_string())
            },
        );
        assert_eq!(result, Ok(()), "cache hit must return Ok without verifying");
        assert_eq!(executed, 0, "real verification must not run on a hit");
    }

    #[test]
    fn failed_verify_is_not_cached_and_reruns_next_time() {
        let (_dir, cache) = temp_cache(true);
        let mut executed = 0u64;
        for _ in 0..2 {
            let result = cache.verify(
                CryptoSigVerifyPoint::Crl,
                b"tbs",
                b"sig",
                b"spki",
                || -> Result<(), String> {
                    executed += 1;
                    Err("bad signature".to_string())
                },
            );
            assert!(result.is_err());
        }
        assert_eq!(executed, 2, "no negative caching: every call re-verifies");
        let key = compute_cache_key(CryptoSigVerifyPoint::Crl, b"tbs", b"sig", b"spki");
        assert!(!cache.contains(&key));
        let summary = cache.summary();
        let stats = summary.per_point.get("crl").expect("stats present");
        assert_eq!(stats.new_keys, 2);
        assert_eq!(stats.verify_executed, 2);
        assert_eq!(stats.verify_skipped, 0);
    }

    #[test]
    fn observe_only_mode_counts_would_hit_but_still_verifies() {
        let (_dir, cache) = temp_cache(false);
        let key = compute_cache_key(CryptoSigVerifyPoint::ChildCaCert, b"tbs", b"sig", b"spki");
        cache.insert_positive(key);
        let mut executed = 0u64;
        cache
            .verify(
                CryptoSigVerifyPoint::ChildCaCert,
                b"tbs",
                b"sig",
                b"spki",
                || -> Result<(), String> {
                    executed += 1;
                    Ok(())
                },
            )
            .expect("verify ok");
        assert_eq!(executed, 1, "observe-only mode must still run the verification");
        let summary = cache.summary();
        assert!(!summary.reuse_enabled);
        let stats = summary
            .per_point
            .get("child_ca_cert")
            .expect("stats present");
        assert_eq!(stats.would_hit, 1);
        assert_eq!(stats.verify_executed, 1);
        assert_eq!(stats.verify_skipped, 0);
    }

    #[test]
    fn cache_file_roundtrips_entries_across_reload() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("work-db.crypto-sig-cache");
        let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
        let key = compute_cache_key(CryptoSigVerifyPoint::EeCertFast, b"tbs", b"sig", b"spki");
        cache.insert_positive(key);
        cache.persist().expect("persist");

        let reloaded = CryptoSigCache::load_or_rebuild(path, true);
        assert!(reloaded.contains(&key));
        let summary = reloaded.summary();
        assert_eq!(summary.entries_loaded, 1);
        assert_eq!(summary.entries_total, 1);
        assert_eq!(summary.schema_version, CACHE_SCHEMA_VERSION);

        // Reloaded entry drives a skip.
        let mut executed = 0u64;
        reloaded
            .verify(
                CryptoSigVerifyPoint::EeCertFast,
                b"tbs",
                b"sig",
                b"spki",
                || -> Result<(), String> {
                    executed += 1;
                    Ok(())
                },
            )
            .expect("verify ok");
        assert_eq!(executed, 0);
    }

    #[test]
    fn cache_file_rebuilds_on_missing_corrupt_or_mismatched_schema() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("work-db.crypto-sig-cache");

        // Missing file.
        let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
        assert_eq!(cache.summary().entries_loaded, 0);

        // Garbage content.
        std::fs::write(&path, b"not a valid cache file").expect("write garbage");
        let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
        assert_eq!(cache.summary().entries_loaded, 0);

        // M1 seen-set format (RPKICSC1) must not load as a v2 cache: cold start.
        let mut bytes = Vec::new();
        bytes.extend_from_slice(b"RPKICSC1");
        bytes.extend_from_slice(&1u64.to_le_bytes());
        bytes.extend_from_slice(&[7u8; 32]);
        std::fs::write(&path, &bytes).expect("write v1 file");
        let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
        assert_eq!(cache.summary().entries_loaded, 0);

        // Valid v2 header but truncated body.
        let mut bytes = Vec::new();
        bytes.extend_from_slice(CACHE_FILE_MAGIC);
        bytes.extend_from_slice(&2u64.to_le_bytes());
        bytes.extend_from_slice(&[9u8; RECORD_BYTES]);
        std::fs::write(&path, &bytes).expect("write truncated");
        let cache = CryptoSigCache::load_or_rebuild(path, true);
        assert_eq!(cache.summary().entries_loaded, 0);
    }

    #[test]
    fn size_cap_triggers_full_rebuild_eviction() {
        let dir = tempfile::tempdir().expect("tempdir");
        let cache = CryptoSigCache::load_or_rebuild_with_capacity(
            dir.path().join("work-db.crypto-sig-cache"),
            true,
            2,
        );
        let k1 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"a", b"s", b"k");
        let k2 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"b", b"s", b"k");
        let k3 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"c", b"s", b"k");
        cache.insert_positive(k1);
        cache.insert_positive(k2);
        assert!(cache.contains(&k1) && cache.contains(&k2));
        // Third insert exceeds the cap -> full rebuild, then insert.
        cache.insert_positive(k3);
        assert!(!cache.contains(&k1));
        assert!(!cache.contains(&k2));
        assert!(cache.contains(&k3));
        let summary = cache.summary();
        assert_eq!(summary.entries_total, 1);
        assert_eq!(summary.evictions_total, 1);
    }

    #[test]
    fn default_cache_file_path_mirrors_pp_cache_index_placement() {
        let db = Path::new("/state/db/work-db");
        assert_eq!(
            default_cache_file_path(db),
            PathBuf::from("/state/db/work-db.crypto-sig-cache")
        );
    }

    #[test]
    fn verify_with_cache_without_global_cache_runs_verify_and_collects_nothing() {
        let _guard = GLOBAL_TEST_LOCK.lock().expect("test lock");
        clear_global();
        assert!(global_cache().is_none());
        let mut executed = 0u64;
        let result: Result<u64, String> = (|| {
            verify_with_cache(
                CryptoSigVerifyPoint::CmsSignedObject,
                b"input",
                b"sig",
                b"spki",
                || -> Result<(), String> {
                    executed += 1;
                    Ok(())
                },
            )?;
            Ok(42)
        })();
        assert_eq!(result, Ok(42));
        assert_eq!(executed, 1);
        assert!(global_cache().is_none());
    }

    #[test]
    fn verify_with_cache_preserves_result_through_global_install() {
        let _guard = GLOBAL_TEST_LOCK.lock().expect("test lock");
        clear_global();
        let dir = tempfile::tempdir().expect("tempdir");
        let cache = Arc::new(CryptoSigCache::load_or_rebuild(
            dir.path().join("work-db.crypto-sig-cache"),
            true,
        ));
        install_global(Arc::clone(&cache));

        let ok: Result<(), String> = verify_with_cache(
            CryptoSigVerifyPoint::ChildCaCert,
            b"tbs",
            b"sig",
            b"spki",
            || Ok(()),
        );
        assert_eq!(ok, Ok(()));
        let err: Result<(), String> = verify_with_cache(
            CryptoSigVerifyPoint::ChildCaCert,
            b"tbs2",
            b"sig",
            b"spki",
            || Err("bad signature".to_string()),
        );
        assert_eq!(err, Err("bad signature".to_string()));
        let summary = cache.summary();
        let stats = summary
            .per_point
            .get("child_ca_cert")
            .expect("stats present");
        assert_eq!(stats.calls, 2);
        assert_eq!(stats.new_keys, 2);
        clear_global();
        assert!(global_cache().is_none());
    }
}
