//! Server cache directives parsed off a response envelope (,
//! SEP-2549).
//!
//! The 2026-07-28 spec adds two optional fields a server can attach to
//! `tools/list`, `resources/list`, `resources/read`, and `prompts/list`
//! responses:
//!
//! - `ttlMs` (number): how long the client may cache the result, in
//!   milliseconds. Modeled on HTTP `Cache-Control: max-age`.
//! - `cacheScope` (string): one of `public`, `private`, or `no-store`.
//!   Modeled on HTTP `Cache-Control: public | private | no-store`.
//!
//! v1 parses both fields and wires `cacheScope` into the
//! eligibility engine (a `no-store` response is never cacheable). The
//! `ttlMs` expiry-on-read is deferred to v2 because it needs the
//! cache-store entry shape to carry an `expires_at_unix` field.

use serde_json::Value;

/// Cache-scope reading of the server directive.
///
/// `Unset` (the default) means the server did not declare a scope, so
/// the client falls back to its own eligibility rules. `Public` and
/// `Private` are advisory in v1 (they affect shared-cache semantics we
/// do not implement yet). `NoStore` is enforced: a response with this
/// scope is never cacheable, regardless of any `cache:` directive.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum CacheScope {
    /// Server omitted `cacheScope`. Default in v1.
    #[default]
    Unset,
    /// `cacheScope: public`. Cacheable in shared caches.
    Public,
    /// `cacheScope: private`. Cacheable in per-client caches only.
    Private,
    /// `cacheScope: no-store`. Never cacheable.
    NoStore,
}

/// Parsed `ttlMs` + `cacheScope` from a response envelope.
///
/// Held as plain data so the runner can build it once per response and
/// pass it to the eligibility engine. The cache store will consume
/// `ttl_ms` for expiry-on-read v2.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ServerCacheDirective {
    /// `ttlMs` parsed off the envelope, when present and non-negative.
    /// `None` means the server did not declare a TTL.
    pub ttl_ms: Option<u64>,
    /// `cacheScope` parsed off the envelope.
    pub scope: CacheScope,
}

impl ServerCacheDirective {
    /// True when the directive instructs the client to never cache the
    /// result. The eligibility engine consults this in v1; v2 also
    /// honors `ttl_ms` for expiry-on-read.
    pub fn no_store(&self) -> bool {
        matches!(self.scope, CacheScope::NoStore)
    }
}

/// Parse the cache directive off a response envelope.
///
/// Looks for `ttlMs` and `cacheScope` at the top level of the value
/// first; if either is missing there it falls back to the `result`
/// wrapper many MCP servers nest under. Returns an empty directive
/// when both are absent so the caller can treat "no directive" the
/// same as "default eligibility".
pub fn parse_from_value(value: &Value) -> ServerCacheDirective {
    let ttl_ms = read_ttl_ms(value);
    let scope = read_cache_scope(value);
    ServerCacheDirective { ttl_ms, scope }
}

/// Read `ttlMs` off the envelope. Tries the top level first, then the
/// nested `result` object. Negative or non-integer values are dropped
/// rather than clamped because the spec's intent is unclear there and
/// a silent clamp could mask a server bug.
fn read_ttl_ms(value: &Value) -> Option<u64> {
    fn at(node: &Value) -> Option<u64> {
        node.get("ttlMs").and_then(Value::as_u64)
    }
    at(value).or_else(|| value.get("result").and_then(at))
}

/// Read `cacheScope` off the envelope. Tries the top level first, then
/// the nested `result` object. Unknown values return `CacheScope::Unset`
/// rather than failing the parse: a typo on the server side should not
/// brick a run, just leave caching at the client-side default.
fn read_cache_scope(value: &Value) -> CacheScope {
    fn at(node: &Value) -> Option<&str> {
        node.get("cacheScope").and_then(Value::as_str)
    }
    let raw = at(value).or_else(|| value.get("result").and_then(at));
    match raw {
        Some("public") => CacheScope::Public,
        Some("private") => CacheScope::Private,
        Some("no-store") => CacheScope::NoStore,
        _ => CacheScope::Unset,
    }
}

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

    #[test]
    fn empty_envelope_yields_a_default_directive() {
        let d = parse_from_value(&json!({}));
        assert_eq!(d.ttl_ms, None);
        assert_eq!(d.scope, CacheScope::Unset);
        assert!(!d.no_store());
    }

    #[test]
    fn parses_top_level_ttl_ms_and_cache_scope() {
        let d = parse_from_value(&json!({
            "ttlMs": 60000,
            "cacheScope": "public",
            "result": { "tools": [] }
        }));
        assert_eq!(d.ttl_ms, Some(60_000));
        assert_eq!(d.scope, CacheScope::Public);
    }

    #[test]
    fn falls_back_to_result_wrapper() {
        let d = parse_from_value(&json!({
            "result": {
                "ttlMs": 1500,
                "cacheScope": "private",
                "tools": []
            }
        }));
        assert_eq!(d.ttl_ms, Some(1500));
        assert_eq!(d.scope, CacheScope::Private);
    }

    #[test]
    fn no_store_floors_cacheability() {
        let d = parse_from_value(&json!({
            "ttlMs": 0,
            "cacheScope": "no-store"
        }));
        assert!(d.no_store());
        assert_eq!(d.scope, CacheScope::NoStore);
    }

    #[test]
    fn unknown_scope_falls_back_to_unset_rather_than_erroring() {
        let d = parse_from_value(&json!({ "cacheScope": "weird" }));
        assert_eq!(d.scope, CacheScope::Unset);
    }

    #[test]
    fn negative_ttl_is_dropped_rather_than_clamped() {
        // `as_u64` returns None for negative numbers; assert that path.
        let d = parse_from_value(&json!({ "ttlMs": -100 }));
        assert_eq!(d.ttl_ms, None);
    }

    #[test]
    fn non_integer_ttl_is_dropped() {
        let d = parse_from_value(&json!({ "ttlMs": "10s" }));
        assert_eq!(d.ttl_ms, None);
    }
}
