//! GitHub Actions cache backend.
//!
//! Why a dedicated module: a CI run that re-tests the same MCP server on
//! every push pays for fresh tool invocations every time unless the
//! cache crosses workflow boundaries. GitHub's Actions cache API gives
//! us a free, per-repo, durable store. When the runtime detects the
//! standard Actions env vars (`GITHUB_ACTIONS=true`, `ACTIONS_CACHE_URL`,
//! `ACTIONS_RUNTIME_TOKEN`), this backend transparently swaps in.
//! Outside CI the same code falls back to the existing [`FsCacheStore`]
//! via [`select_backend`].
//!
//! Wire shape (mirrors `@actions/cache`):
//!
//! - `POST {cache_url}_apis/artifactcache/caches` reserves a cache id.
//! - `PATCH {cache_url}_apis/artifactcache/caches/{id}` uploads the
//!   payload in `Content-Range` chunks (32 MiB each).
//! - `POST {cache_url}_apis/artifactcache/caches/{id}` commits the entry
//!   with the final content length.
//! - `GET {cache_url}_apis/artifactcache/cache?keys=...&version=...`
//!   returns an `archiveLocation` URL the client follows for the bytes.
//!
//! The Actions cache API expects opaque `version` strings; we set it to
//! the crate version so a tool upgrade invalidates every entry. Keys
//! arrive pre-derived from `cache::store::derive()` and are prefixed
//! with `mcptest-<version>-` so a shared workflow cache stays segregated
//! across tools and major versions.
//!
//! Platform limits to remember (enforced by GitHub, not by this code):
//!
//! - **10 GiB total cache size** per repository; oldest entries are
//!   evicted to stay under the cap.
//! - **7 day eviction policy**: an entry that goes 7 days without a read
//!   gets purged regardless of total size.
//!
//! Those limits inform two design choices here. We do not implement
//! per-key delete or bulk eviction (the platform handles it), and we
//! retry transient 5xx responses once because the cache service is
//! occasionally flaky during platform deploys.

use std::time::Duration;

use reqwest::blocking::{Client, RequestBuilder};
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_RANGE, CONTENT_TYPE};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};

use super::store::{CacheEntry, CacheError, CacheStore, FsCacheStore};

/// Static tool prefix on every key stored in the Actions cache.
///
/// Combined with the crate version (see [`key_prefix`]) so a tool
/// upgrade does not read stale entries, and so multiple tools sharing
/// one repository's cache namespace stay segregated.
pub const TOOL_PREFIX: &str = "mcptest-";

/// Build the full `mcptest-<version>-` prefix every entry gets.
///
/// Exposed so tests can assert on the exact wire string without
/// depending on the crate version literal.
pub fn key_prefix(version: &str) -> String {
    format!("{TOOL_PREFIX}{version}-")
}

/// Version string sent alongside every cache lookup.
///
/// Actions cache treats `version` as opaque, so we point it at the
/// crate's own version. Upgrading mcptest invalidates the cache
/// namespace; older runs do not see new-format entries.
// Sourced from `crate::version::semver()` at call time so the cache
// namespace bumps with the Cargo package version. We keep this as a
// function rather than a `pub const` because the version module returns
// `&'static str` references rather than literal constants and the GHA
// loader only reads `CACHE_VERSION` from a handful of call sites.
/// Version string sent alongside every cache lookup. See module docs.
pub fn cache_version() -> &'static str {
    crate::version::semver()
}

/// API version header value expected by the Actions cache service.
const API_VERSION: &str = "6.0-preview.1";

/// Chunk size for the streaming PATCH upload. The toolkit defaults to
/// 32 MiB; we mirror that so behavior matches established tooling.
const CHUNK_SIZE: usize = 32 * 1024 * 1024;

/// Per-request timeout. Generous because the upload step can be slow on
/// a cold cache.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);

/// How many times we retry a transient (5xx) response before giving up.
/// One retry is enough for the brief blips we see during platform
/// deploys; more would just delay the fallback path on hard outages.
const MAX_RETRIES: u32 = 2;

/// Backoff between retries. Small because the cache service is in the
/// same datacenter as the runner; a short pause is plenty.
const RETRY_BACKOFF: Duration = Duration::from_millis(250);

/// Documented platform cap on total cache size per repository.
pub const PLATFORM_MAX_BYTES: u64 = 10 * 1024 * 1024 * 1024;

/// Documented platform eviction window. Entries idle for longer than
/// this are purged by GitHub regardless of cap.
pub const PLATFORM_EVICTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);

/// Environment-driven configuration for the Actions cache.
///
/// Builders read this off process env; tests construct it directly.
#[derive(Debug, Clone)]
pub struct GhaConfig {
    /// `ACTIONS_CACHE_URL`. Always ends in `/`.
    pub cache_url: String,
    /// `ACTIONS_RUNTIME_TOKEN` (a bearer JWT minted per job).
    pub runtime_token: String,
    /// Opaque version string. Defaults to [`cache_version`]; tests
    /// override to keep their fixtures isolated from the live crate
    /// version.
    pub version: String,
}

impl GhaConfig {
    /// Try to read the configuration from the process environment.
    ///
    /// Returns `None` when any of the three variables is missing or
    /// empty, which is the normal local-development case. Callers fall
    /// back to the filesystem backend in that case. We require all
    /// three so an accidentally-set `ACTIONS_CACHE_URL` on a laptop
    /// does not divert traffic at GitHub's API.
    pub fn from_env() -> Option<Self> {
        let in_actions = std::env::var("GITHUB_ACTIONS").ok()?;
        if in_actions != "true" {
            return None;
        }
        let cache_url = std::env::var("ACTIONS_CACHE_URL").ok()?;
        let runtime_token = std::env::var("ACTIONS_RUNTIME_TOKEN").ok()?;
        if cache_url.is_empty() || runtime_token.is_empty() {
            return None;
        }
        let normalized = if cache_url.ends_with('/') {
            cache_url
        } else {
            format!("{cache_url}/")
        };
        Some(Self {
            cache_url: normalized,
            runtime_token,
            version: cache_version().to_string(),
        })
    }
}

/// GitHub Actions-backed cache store.
///
/// Implements [`CacheStore`] with the same `get`/`put`/`delete`/`list`/
/// `evict` shape as [`FsCacheStore`]. Operations that the Actions API
/// does not natively support (per-key delete, `list`, `evict`) degrade
/// to no-ops because the platform's seven-day eviction policy is the
/// authoritative cleaner.
#[derive(Debug, Clone)]
pub struct GhaCacheStore {
    config: GhaConfig,
    client: Client,
}

impl GhaCacheStore {
    /// Build a store from explicit configuration. Use this from tests
    /// (paired with `wiremock`) so the request shape can be inspected
    /// without touching real GitHub infrastructure.
    pub fn new(config: GhaConfig) -> Result<Self, CacheError> {
        let client = Client::builder()
            .timeout(REQUEST_TIMEOUT)
            .build()
            .map_err(|e| CacheError::Io {
                path: std::path::PathBuf::from(&config.cache_url),
                source: std::io::Error::other(e.to_string()),
            })?;
        Ok(Self { config, client })
    }

    /// Build a store from the standard Actions env vars.
    ///
    /// Returns `Ok(None)` when the env vars are not set, which is the
    /// signal callers use to fall back to the filesystem backend.
    pub fn from_env() -> Result<Option<Self>, CacheError> {
        match GhaConfig::from_env() {
            Some(cfg) => Self::new(cfg).map(Some),
            None => Ok(None),
        }
    }

    fn auth(&self, builder: RequestBuilder) -> RequestBuilder {
        builder
            .header(
                AUTHORIZATION,
                format!("Bearer {}", self.config.runtime_token),
            )
            .header(
                ACCEPT,
                format!("application/json;api-version={API_VERSION}"),
            )
    }

    fn endpoint(&self, suffix: &str) -> String {
        format!("{}_apis/artifactcache/{suffix}", self.config.cache_url)
    }

    fn prefixed(&self, key: &str) -> String {
        format!("{}{key}", key_prefix(&self.config.version))
    }

    /// Reject a non-success response from the cache service.
    ///
    /// Why this exists: every cache operation followed the same check and
    /// every one mislabeled an HTTP failure as [`CacheError::Corrupted`].
    /// A 500 from the cache service is a backend problem, not a bad
    /// checksum, so this maps it to [`CacheError::Backend`] and tags it
    /// with `op` so the runner log names the failing call. Genuine
    /// integrity failures (truncated body, bad checksum) stay
    /// [`CacheError::Corrupted`] at their own call sites.
    fn ensure_success(
        resp: reqwest::blocking::Response,
        op: &str,
    ) -> Result<reqwest::blocking::Response, CacheError> {
        let status = resp.status();
        if status.is_success() {
            Ok(resp)
        } else {
            Err(CacheError::Backend {
                op: op.to_string(),
                status: status.as_u16(),
            })
        }
    }

    fn lookup(&self, key: &str) -> Result<Option<String>, CacheError> {
        let url = self.endpoint("cache");
        let prefixed = self.prefixed(key);
        let version = self.config.version.clone();
        let resp = self.send_with_retry("lookup", &url, || {
            self.auth(self.client.get(&url))
                .query(&[("keys", prefixed.clone()), ("version", version.clone())])
        })?;
        let status = resp.status();
        // Both 204 (the toolkit's signal) and 404 (older Actions cache
        // versions, or stale archive links) mean "no entry for this
        // key"; treat them the same so callers do not have to.
        if status == StatusCode::NO_CONTENT || status == StatusCode::NOT_FOUND {
            return Ok(None);
        }
        let resp = Self::ensure_success(resp, "lookup")?;
        let body: LookupBody = resp.json().map_err(io_err)?;
        Ok(body.archive_location)
    }

    fn reserve(&self, key: &str) -> Result<i64, CacheError> {
        let url = self.endpoint("caches");
        let prefixed = self.prefixed(key);
        let version = self.config.version.clone();
        let resp = self.send_with_retry("reserve", &url, || {
            self.auth(self.client.post(&url))
                .header(CONTENT_TYPE, "application/json")
                .json(&ReserveBody {
                    key: prefixed.clone(),
                    version: version.clone(),
                })
        })?;
        let resp = Self::ensure_success(resp, "reserve")?;
        let body: ReserveResponse = resp.json().map_err(io_err)?;
        Ok(body.cache_id)
    }

    fn upload_chunks(&self, cache_id: i64, value: &[u8]) -> Result<(), CacheError> {
        let url = self.endpoint(&format!("caches/{cache_id}"));
        let mut start = 0usize;
        while start < value.len() {
            let end = (start + CHUNK_SIZE).min(value.len());
            let range = format!("bytes {start}-{}/*", end - 1);
            let chunk: Vec<u8> = value[start..end].to_vec();
            let resp = self.send_with_retry("upload chunk", &url, || {
                self.auth(self.client.patch(&url))
                    .header(CONTENT_TYPE, "application/octet-stream")
                    .header(CONTENT_RANGE, range.clone())
                    .body(chunk.clone())
            })?;
            Self::ensure_success(resp, "upload chunk")?;
            start = end;
        }
        Ok(())
    }

    fn commit(&self, cache_id: i64, size: usize) -> Result<(), CacheError> {
        let url = self.endpoint(&format!("caches/{cache_id}"));
        let resp = self.send_with_retry("commit", &url, || {
            self.auth(self.client.post(&url))
                .header(CONTENT_TYPE, "application/json")
                .json(&CommitBody { size: size as i64 })
        })?;
        Self::ensure_success(resp, "commit")?;
        Ok(())
    }

    fn download(&self, archive_url: &str) -> Result<Vec<u8>, CacheError> {
        let resp =
            self.send_with_retry("download", archive_url, || self.client.get(archive_url))?;
        let resp = Self::ensure_success(resp, "download")?;
        let bytes = resp.bytes().map_err(io_err)?;
        Ok(bytes.to_vec())
    }

    /// Issue a request, retrying transient 5xx responses up to
    /// [`MAX_RETRIES`] times.
    ///
    /// Auth failures (401/403) skip the retry loop and surface
    /// immediately as a [`CacheError::Backend`], because no number of
    /// retries will mint a new token. The closure rebuilds the request
    /// from scratch every attempt so each call carries a fresh body
    /// (`reqwest::blocking::RequestBuilder` is not cloneable once a body
    /// is attached).
    fn send_with_retry<F>(
        &self,
        op: &'static str,
        _url: &str,
        mut build: F,
    ) -> Result<reqwest::blocking::Response, CacheError>
    where
        F: FnMut() -> RequestBuilder,
    {
        let mut last_status: Option<StatusCode> = None;
        for attempt in 0..=MAX_RETRIES {
            let resp = build().send().map_err(io_err)?;
            let status = resp.status();
            if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
                return Err(CacheError::Backend {
                    op: format!("{op} (unauthorized)"),
                    status: status.as_u16(),
                });
            }
            if status.is_server_error() && attempt < MAX_RETRIES {
                last_status = Some(status);
                std::thread::sleep(RETRY_BACKOFF);
                continue;
            }
            return Ok(resp);
        }
        // Defensive: the loop always returns or continues. If we ever
        // fall off the end, surface the last seen status so the caller
        // sees a coherent error instead of a panic.
        let status = last_status.map(|s| s.as_u16()).unwrap_or(0);
        Err(CacheError::Backend {
            op: format!("{op} (after {MAX_RETRIES} retries)"),
            status,
        })
    }
}

#[derive(Debug, Serialize)]
struct ReserveBody {
    key: String,
    version: String,
}

#[derive(Debug, Deserialize)]
struct ReserveResponse {
    #[serde(rename = "cacheId")]
    cache_id: i64,
}

#[derive(Debug, Serialize)]
struct CommitBody {
    size: i64,
}

#[derive(Debug, Deserialize)]
struct LookupBody {
    #[serde(rename = "archiveLocation")]
    archive_location: Option<String>,
}

fn io_err(e: reqwest::Error) -> CacheError {
    CacheError::Io {
        path: std::path::PathBuf::from("<gha-cache>"),
        source: std::io::Error::other(e.to_string()),
    }
}

impl CacheStore for GhaCacheStore {
    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
        let Some(archive_url) = self.lookup(key)? else {
            return Ok(None);
        };
        Ok(Some(self.download(&archive_url)?))
    }

    fn put(&self, key: &str, value: &[u8]) -> Result<(), CacheError> {
        let cache_id = self.reserve(key)?;
        self.upload_chunks(cache_id, value)?;
        self.commit(cache_id, value.len())
    }

    fn delete(&self, _key: &str) -> Result<(), CacheError> {
        // The Actions cache API does not expose a per-key delete.
        // Eviction is handled by the platform's seven-day policy and by
        // version bumps. Treat as a no-op so callers can use the same
        // store contract across backends.
        Ok(())
    }

    fn list(&self) -> Result<Vec<String>, CacheError> {
        // The API supports prefix-search through key matching but not a
        // bulk enumeration. The runner uses `list` only for local
        // bookkeeping, so an empty response is acceptable here.
        Ok(Vec::new())
    }

    fn evict(&self, _predicate: &dyn Fn(&CacheEntry) -> bool) -> Result<usize, CacheError> {
        // Same reason as `delete`: platform handles eviction. Returning
        // zero keeps the trait happy without lying about removals.
        Ok(0)
    }
}

/// Pick the GHA backend when env vars are present, otherwise fall back
/// to the filesystem store at the user's cache dir.
///
/// Centralised here so the runner crate, the CLI, and SDK hosts all
/// resolve the backend identically. Returns a boxed trait object so the
/// caller does not have to switch on the variant.
pub fn select_backend() -> Result<Box<dyn CacheStore>, CacheError> {
    if let Some(store) = GhaCacheStore::from_env()? {
        return Ok(Box::new(store));
    }
    let fs = FsCacheStore::user_default()?;
    Ok(Box::new(fs))
}

#[cfg(test)]
mod tests {
    use super::*;
    use wiremock::matchers::{header, method, path, query_param};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn rt() -> tokio::runtime::Runtime {
        tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap()
    }

    fn config_for(server: &MockServer) -> GhaConfig {
        GhaConfig {
            cache_url: format!("{}/", server.uri()),
            runtime_token: "test-token".into(),
            version: "test-version".into(),
        }
    }

    /// Serialize every env-touching test through this lock.
    ///
    /// Rust runs `#[test]` functions in parallel by default and process
    /// env is global; two of these tests racing on `GITHUB_ACTIONS`
    /// observe each other's writes. Holding the lock around the whole
    /// test (capture, mutate, assert, restore) keeps each one
    /// self-contained.
    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    /// Snapshot the three Actions env vars and restore them on drop.
    ///
    /// The `_guard` field carries the global env lock; dropping the
    /// snapshot releases it, so the next env-touching test only sees
    /// the restored state.
    struct EnvSnapshot {
        _guard: std::sync::MutexGuard<'static, ()>,
        actions: Option<String>,
        url: Option<String>,
        token: Option<String>,
    }

    impl EnvSnapshot {
        fn capture() -> Self {
            // Poison is fine for tests: if a sibling panicked we still
            // want to restore env and surface the failure.
            let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
            Self {
                _guard: guard,
                actions: std::env::var("GITHUB_ACTIONS").ok(),
                url: std::env::var("ACTIONS_CACHE_URL").ok(),
                token: std::env::var("ACTIONS_RUNTIME_TOKEN").ok(),
            }
        }
    }

    impl Drop for EnvSnapshot {
        fn drop(&mut self) {
            restore("GITHUB_ACTIONS", self.actions.as_deref());
            restore("ACTIONS_CACHE_URL", self.url.as_deref());
            restore("ACTIONS_RUNTIME_TOKEN", self.token.as_deref());
        }
    }

    fn restore(name: &str, value: Option<&str>) {
        match value {
            Some(v) => std::env::set_var(name, v),
            None => std::env::remove_var(name),
        }
    }

    #[test]
    fn from_env_returns_none_when_vars_missing() {
        let _snap = EnvSnapshot::capture();
        std::env::remove_var("GITHUB_ACTIONS");
        std::env::remove_var("ACTIONS_CACHE_URL");
        std::env::remove_var("ACTIONS_RUNTIME_TOKEN");
        assert!(GhaConfig::from_env().is_none());
    }

    #[test]
    fn from_env_returns_none_when_github_actions_flag_is_false() {
        let _snap = EnvSnapshot::capture();
        std::env::set_var("GITHUB_ACTIONS", "false");
        std::env::set_var("ACTIONS_CACHE_URL", "https://cache.example/");
        std::env::set_var("ACTIONS_RUNTIME_TOKEN", "tok");
        assert!(GhaConfig::from_env().is_none());
    }

    #[test]
    fn from_env_appends_trailing_slash() {
        let _snap = EnvSnapshot::capture();
        std::env::set_var("GITHUB_ACTIONS", "true");
        std::env::set_var("ACTIONS_CACHE_URL", "https://cache.example");
        std::env::set_var("ACTIONS_RUNTIME_TOKEN", "tok");
        let cfg = GhaConfig::from_env().expect("present");
        assert!(cfg.cache_url.ends_with('/'));
    }

    #[test]
    fn get_returns_none_when_lookup_responds_no_content() {
        let runtime = rt();
        let (uri_cfg, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("GET"))
                .and(path("/_apis/artifactcache/cache"))
                .and(query_param("keys", "mcptest-test-version-abc"))
                .respond_with(ResponseTemplate::new(204))
                .mount(&server)
                .await;
            (config_for(&server), server)
        });

        let store = GhaCacheStore::new(uri_cfg).expect("client");
        let got = store.get("abc").expect("call");
        assert!(got.is_none());
    }

    #[test]
    fn get_returns_none_when_lookup_responds_not_found() {
        // Some Actions cache versions return 404 instead of 204; the
        // backend must treat them as equivalent so callers can rely on
        // a single "missing" signal.
        let runtime = rt();
        let (uri_cfg, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("GET"))
                .and(path("/_apis/artifactcache/cache"))
                .respond_with(ResponseTemplate::new(404))
                .mount(&server)
                .await;
            (config_for(&server), server)
        });

        let store = GhaCacheStore::new(uri_cfg).expect("client");
        let got = store.get("abc").expect("call");
        assert!(got.is_none());
    }

    #[test]
    fn put_then_get_round_trips_via_mock_api() {
        let runtime = rt();
        let payload: Vec<u8> = b"hello mcptest".to_vec();
        let (uri_cfg, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            let archive_path = "/files/abc.bin";

            Mock::given(method("POST"))
                .and(path("/_apis/artifactcache/caches"))
                .and(header("authorization", "Bearer test-token"))
                .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
                    "cacheId": 42_i64,
                })))
                .mount(&server)
                .await;

            Mock::given(method("PATCH"))
                .and(path("/_apis/artifactcache/caches/42"))
                .respond_with(ResponseTemplate::new(204))
                .mount(&server)
                .await;

            Mock::given(method("POST"))
                .and(path("/_apis/artifactcache/caches/42"))
                .respond_with(ResponseTemplate::new(204))
                .mount(&server)
                .await;

            let archive_full = format!("{}{archive_path}", server.uri());
            Mock::given(method("GET"))
                .and(path("/_apis/artifactcache/cache"))
                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                    "archiveLocation": archive_full,
                })))
                .mount(&server)
                .await;

            Mock::given(method("GET"))
                .and(path(archive_path))
                .respond_with(ResponseTemplate::new(200).set_body_bytes(payload.clone()))
                .mount(&server)
                .await;

            (config_for(&server), server)
        });

        let store = GhaCacheStore::new(uri_cfg).expect("client");
        store.put("abc", &payload).expect("put");
        let got = store.get("abc").expect("get");
        assert_eq!(got.as_deref(), Some(&payload[..]));
    }

    #[test]
    fn put_surfaces_reserve_error_as_backend() {
        // A 5xx from the cache service is a backend failure, not local
        // corruption. After  the backend classifies it as
        // CacheError::Backend so callers can fall back to recomputing
        // instead of purging a (perfectly good) entry.
        let runtime = rt();
        let (uri_cfg, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/_apis/artifactcache/caches"))
                .respond_with(ResponseTemplate::new(500))
                .mount(&server)
                .await;
            (config_for(&server), server)
        });

        let store = GhaCacheStore::new(uri_cfg).expect("client");
        let err = store.put("abc", b"x").unwrap_err();
        match err {
            CacheError::Backend { op, status } => {
                assert!(op.contains("reserve"), "op should name the call: {op}");
                // The reserve path retries 5xx, so the status surfaced is
                // the last seen 500 through the after-retries branch.
                assert_eq!(status, 500);
            }
            other => panic!("expected Backend, got {other:?}"),
        }
    }

    #[test]
    fn delete_and_list_and_evict_are_noops() {
        // No mock server: the calls must not hit the network.
        let cfg = GhaConfig {
            cache_url: "http://127.0.0.1:1/".into(),
            runtime_token: "tok".into(),
            version: "v".into(),
        };
        let store = GhaCacheStore::new(cfg).expect("client");
        store.delete("anything").unwrap();
        assert_eq!(store.list().unwrap(), Vec::<String>::new());
        assert_eq!(store.evict(&|_| true).unwrap(), 0);
    }

    #[test]
    fn key_prefix_is_stable() {
        assert!(key_prefix("1").starts_with("mcptest-"));
        assert_eq!(key_prefix("9.9.9"), "mcptest-9.9.9-");
    }

    #[test]
    fn reserve_retries_transient_5xx_then_succeeds() {
        // Why: GitHub's cache service occasionally returns 5xx during
        // platform deploys. A one-shot failure should not fail the run
        // when a quick retry would succeed. Mounting two mocks in order
        // (500 first, then 201) is the cleanest way to assert the
        // backend retried once.
        let runtime = rt();
        let (uri_cfg, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            // First call returns 500 (transient).
            Mock::given(method("POST"))
                .and(path("/_apis/artifactcache/caches"))
                .respond_with(ResponseTemplate::new(500))
                .up_to_n_times(1)
                .mount(&server)
                .await;
            // Subsequent call returns success.
            Mock::given(method("POST"))
                .and(path("/_apis/artifactcache/caches"))
                .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
                    "cacheId": 7_i64,
                })))
                .mount(&server)
                .await;
            Mock::given(method("PATCH"))
                .and(path("/_apis/artifactcache/caches/7"))
                .respond_with(ResponseTemplate::new(204))
                .mount(&server)
                .await;
            Mock::given(method("POST"))
                .and(path("/_apis/artifactcache/caches/7"))
                .respond_with(ResponseTemplate::new(204))
                .mount(&server)
                .await;
            (config_for(&server), server)
        });

        let store = GhaCacheStore::new(uri_cfg).expect("client");
        store.put("abc", b"payload").expect("retry then succeed");
    }

    #[test]
    fn auth_failure_surfaces_immediately_without_retry() {
        // Why: 401/403 cannot be fixed by retrying. After  this is
        // a CacheError::Backend (a transport/service problem, not
        // corruption) whose op string mentions "unauthorized" so a runner
        // log makes the cause obvious to whoever is on call.
        let runtime = rt();
        let (uri_cfg, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/_apis/artifactcache/caches"))
                .respond_with(ResponseTemplate::new(401))
                .mount(&server)
                .await;
            (config_for(&server), server)
        });

        let store = GhaCacheStore::new(uri_cfg).expect("client");
        let err = store.put("abc", b"x").unwrap_err();
        match err {
            CacheError::Backend { op, status } => {
                assert!(op.contains("unauthorized"), "op should mention auth: {op}");
                assert_eq!(status, 401);
            }
            other => panic!("expected Backend, got {other:?}"),
        }
    }

    #[test]
    fn select_backend_falls_back_to_fs_when_env_absent() {
        // Why: this is the missing-env-var fallthrough described in
        // . Outside CI the backend must not refuse to run.
        let _snap = EnvSnapshot::capture();
        std::env::remove_var("GITHUB_ACTIONS");
        std::env::remove_var("ACTIONS_CACHE_URL");
        std::env::remove_var("ACTIONS_RUNTIME_TOKEN");
        // If the per-OS cache dir resolves on this platform, the call
        // must succeed and pick the filesystem path. We do not call
        // `put`/`get` here because the goal is to assert the
        // selection logic, not the FS store contract.
        let _store = select_backend().expect("fallback to fs");
    }

    #[test]
    fn platform_constants_match_documented_limits() {
        // Guard against silent edits to the documented limits; these
        // values feed user-facing docs.
        assert_eq!(PLATFORM_MAX_BYTES, 10 * 1024 * 1024 * 1024);
        assert_eq!(PLATFORM_EVICTION.as_secs(), 7 * 24 * 60 * 60);
    }
}
