//! Cache storage layer.
//!
//! Once the eligibility engine decides a test is cacheable,
//! something has to actually stash the bytes and look them up next run.
//! That something is the [`CacheStore`] trait, plus the cache key
//! derivation in [`derive()`] that turns the relevant inputs into a
//! stable filename.
//!
//! The default implementation is filesystem-backed and content addressed
//! (see [`FsCacheStore`]). Remote and in-memory stores are intentionally
//! not in scope for v1.0; if a backend is needed later, implement the
//! trait and the runner picks it up unchanged.

mod fs;
mod key;

use std::path::PathBuf;

use thiserror::Error;

pub use fs::FsCacheStore;
pub use key::{derive, CacheKeyInputs};

/// Storage layer for cached test results.
///
/// Implementations return raw bytes; the runner deserializes them as
/// needed. Why bytes and not a typed envelope? The runner's cached
/// result struct will evolve, but the store does not have to know about
/// the shape, only the size and access time.
pub trait CacheStore: Send + Sync {
    /// Read the bytes stored under `key`, or `None` if absent.
    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError>;

    /// Write `value` under `key`, replacing any previous content.
    fn put(&self, key: &str, value: &[u8]) -> Result<(), CacheError>;

    /// Write `value` under `key` with an expiry deadline. The
    /// default implementation ignores the TTL and forwards to
    /// [`Self::put`] so backends that have not yet learned the
    /// expiry-on-read rule stay correct; the [`FsCacheStore`]
    /// overrides this to thread `expires_at_unix` onto the on-disk
    /// metadata sidecar.
    fn put_with_ttl(
        &self,
        key: &str,
        value: &[u8],
        expires_at_unix: Option<u64>,
    ) -> Result<(), CacheError> {
        let _ = expires_at_unix;
        self.put(key, value)
    }

    /// Remove the entry stored under `key`. Missing entries are a no-op.
    fn delete(&self, key: &str) -> Result<(), CacheError>;

    /// Enumerate every key currently stored.
    fn list(&self) -> Result<Vec<String>, CacheError>;

    /// Walk every entry and delete those for which `predicate` returns
    /// true. Returns the count of removed entries.
    fn evict(&self, predicate: &dyn Fn(&CacheEntry) -> bool) -> Result<usize, CacheError>;
}

/// Metadata describing a single cached entry.
///
/// Returned to predicates passed to [`CacheStore::evict`] so the caller
/// can make TTL or LRU decisions without re-reading the file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CacheEntry {
    /// Cache key (the SHA-256 hex string).
    pub key: String,
    /// On-disk size of the payload in bytes.
    pub size_bytes: u64,
    /// Unix epoch seconds when the entry was first written.
    pub created_at_unix: u64,
    /// Unix epoch seconds when the entry was last read.
    pub accessed_at_unix: u64,
    /// Unix epoch seconds the entry becomes stale. `None` means the
    /// entry has no server-declared TTL and stays in the cache until
    /// the LRU policy evicts it. A `get()` past this timestamp
    /// returns a miss and the store evicts the file in the same
    /// pass.
    pub expires_at_unix: Option<u64>,
}

/// Decide whether a cache entry should be treated as expired given
/// the current unix-epoch time. Pulled out as a free function so
/// callers (the filesystem store, the GHA shim, future Redis/S3
/// backends) share one TTL rule.
///
/// `None` for `expires_at_unix` returns `false`: an entry without a
/// declared TTL never expires on its own. A `Some(deadline)` returns
/// `true` when `now >= deadline`.
pub fn is_expired(expires_at_unix: Option<u64>, now_unix: u64) -> bool {
    match expires_at_unix {
        Some(deadline) => now_unix >= deadline,
        None => false,
    }
}

/// Errors raised by cache store operations.
#[derive(Debug, Error)]
pub enum CacheError {
    /// An I/O operation against the on-disk store failed. Carries the
    /// offending path so the user can see which file went wrong.
    #[error("cache i/o error at {path}: {source}")]
    Io {
        /// Path the store was operating on.
        path: PathBuf,
        /// Underlying I/O error.
        #[source]
        source: std::io::Error,
    },

    /// A metadata sidecar exists but could not be parsed. Usually means
    /// a partial write was interrupted; callers may choose to delete and
    /// re-cache rather than fail the run.
    #[error("corrupted cache metadata at {path}: {reason}")]
    Corrupted {
        /// Path to the corrupted sidecar file.
        path: PathBuf,
        /// Why the parser rejected it.
        reason: String,
    },

    /// A remote cache backend (the GitHub Actions cache service) answered
    /// with a non-success HTTP status. This is a transport or service
    /// failure, not local corruption: the cached bytes, if any, are fine,
    /// so callers can fall back to recomputing rather than purging an
    /// entry. Carries the operation and status so a runner log points at
    /// the failing call (`reserve`, `commit`, and so on).
    #[error("cache backend error during {op}: HTTP {status}")]
    Backend {
        /// The cache operation that failed (for example `reserve`).
        op: String,
        /// The HTTP status the backend returned.
        status: u16,
    },
}
