//! Cache eligibility rules engine.
//!
//! Whether a given test is cacheable depends on the test type, on author
//! declarations (`cache:`, `effects:`, `hooks:`, `server_version:`), and
//! on the transport in use. The rules engine collapses those inputs into
//! a single [`EligibilityDecision`] that the runner consults before it
//! touches the (future) cache store, and that reporters render to the
//! user when a cache miss happens for a structural reason.
//!
//! ```ignore
//! use mcptest_core::cache::{evaluate, EligibilityInput, TestKind, Transport};
//! let input = EligibilityInput {
//!     kind: TestKind::Tools,
//!     directive: None,
//!     effects: &[],
//!     transport: Transport::Stdio,
//!     server_version_pinned: false,
//!     has_hooks: false,
//!     server_directive: None,
//! };
//! assert!(evaluate(input).cacheable);
//! ```

use std::fmt;

/// The kind of test, used to pick the default eligibility.
///
/// Each kind has a baked-in default that reflects how deterministic that
/// kind of test is in practice. Authors override the default with the
/// `cache:` directive when they know better.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TestKind {
    /// Tool-call tests. Default `auto`: cacheable unless something
    /// excludes them.
    Tools,
    /// Compliance tests. Default `always`: read-only protocol probes are
    /// safe to cache even when reached over HTTP.
    Compliance,
    /// Evaluation tests. Default `never`: LLM calls are non-deterministic
    /// and caching a graded score would lie to the user.
    Evals,
    /// Performance tests. Default `never`: timing measurements need a
    /// fresh run to be meaningful.
    Performance,
    /// Model-compatibility tests. Default `auto`: same shape as `Tools`.
    ModelCompatibility,
}

impl fmt::Display for TestKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            TestKind::Tools => "tools",
            TestKind::Compliance => "compliance",
            TestKind::Evals => "evals",
            TestKind::Performance => "performance",
            TestKind::ModelCompatibility => "model_compatibility",
        };
        f.write_str(s)
    }
}

/// Author-declared cache directive on a test or default block.
///
/// `Auto` defers to the test-kind default, `Always` forces cache use, and
/// `Never` opts out. Hard exclusions still win over `Always`; see the
/// precedence rules on [`evaluate`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheDirective {
    /// Use the test-kind default.
    Auto,
    /// Force the test to be cacheable, subject to hard exclusions.
    Always,
    /// Force the test to be uncacheable.
    Never,
}

/// Author-declared effect that a test has on external state.
///
/// `External` is the load-bearing variant: any test that touches state
/// outside the process is excluded from caching no matter what the
/// `cache:` directive says. `Local`, `Filesystem`, and `Custom` are
/// declared for visibility today and stay non-excluding so authors can
/// tag tests honestly without giving up determinism wins.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
    /// Touches external state (network, third-party API, paid service).
    External,
    /// Touches in-process state only. Caching is safe.
    Local,
    /// Touches the local filesystem. Caching is safe at the engine level.
    Filesystem,
    /// Author-tagged effect we do not interpret structurally.
    Custom(String),
}

/// Transport used to talk to the server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
    /// Subprocess over stdio. Server version is implicit in the binary
    /// the user invoked, so no pin is required for caching.
    Stdio,
    /// HTTP transport. The remote server can change under us between
    /// runs, so caching requires an explicit `server_version:` pin.
    Http,
}

/// Inputs the engine uses to decide eligibility.
///
/// Borrowed `effects` keeps callers free to assemble the list inline at
/// the call site without an allocation.
pub struct EligibilityInput<'a> {
    /// Kind of test under consideration.
    pub kind: TestKind,
    /// Explicit `cache:` field on the test, if the author set one.
    pub directive: Option<CacheDirective>,
    /// Author-declared effects.
    pub effects: &'a [Effect],
    /// Transport used to reach the server.
    pub transport: Transport,
    /// True when the YAML pinned `server_version:` for this run.
    pub server_version_pinned: bool,
    /// True when the test declares any `hooks:` block.
    pub has_hooks: bool,
    /// Optional server-side cache directive parsed off the response
    /// envelope. When the directive declares
    /// `cacheScope: no-store`, the eligibility engine refuses to cache
    /// the result regardless of any author `cache:` directive. v1
    /// honors the scope; the `ttl_ms` field is parsed for v2 expiry.
    pub server_directive: Option<super::ServerCacheDirective>,
}

/// Decision plus reason.
///
/// The reason is what reporters render after the colon in
/// `"cache miss: <reason>"`, so it has to stand on its own without
/// surrounding context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EligibilityDecision {
    /// Whether the runner may consult the cache for this test.
    pub cacheable: bool,
    /// Structured reason for the decision.
    pub reason: EligibilityReason,
}

/// Why a test is or is not cacheable.
///
/// Structured so reporters can pick their own phrasing per output mode
/// (pretty vs JSON vs JUnit) without parsing free-form strings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EligibilityReason {
    /// Cacheable, the test-kind default applies.
    Default,
    /// Cacheable, the author set `cache: always`.
    DirectiveAlways,
    /// Not cacheable, the author set `cache: never`.
    DirectiveNever,
    /// Not cacheable, the test declares an external effect.
    EffectExternal,
    /// Not cacheable, the test declares hooks.
    HasHooks,
    /// Not cacheable, HTTP transport without a `server_version` pin.
    HttpWithoutServerVersion,
    /// Not cacheable, the type default for this kind is `never`.
    TypeDefault(TestKind),
    /// Not cacheable, the server set `cacheScope: no-store` on the
    /// response envelope. Server directives win
    /// over `cache: always`.
    ServerNoStore,
}

impl fmt::Display for EligibilityReason {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            EligibilityReason::Default => f.write_str("cacheable (type default)"),
            EligibilityReason::DirectiveAlways => f.write_str("cacheable (cache: always)"),
            EligibilityReason::DirectiveNever => f.write_str("not cacheable (cache: never)"),
            EligibilityReason::EffectExternal => {
                f.write_str("not cacheable (effect: external declared)")
            }
            EligibilityReason::HasHooks => f.write_str("not cacheable (test has hooks)"),
            EligibilityReason::HttpWithoutServerVersion => {
                f.write_str("not cacheable (HTTP transport without server_version pin)")
            }
            EligibilityReason::TypeDefault(kind) => {
                write!(f, "not cacheable (test type default for {kind})")
            }
            EligibilityReason::ServerNoStore => {
                f.write_str("not cacheable (server set cacheScope: no-store)")
            }
        }
    }
}

/// Decide whether a test is eligible for caching.
///
/// Precedence, highest first:
/// 1. Explicit `cache: never` directive.
/// 2. Hard exclusions: hooks, HTTP without `server_version`, external effects.
/// 3. Explicit `cache: always` directive.
/// 4. Type default for the [`TestKind`].
///
/// HTTP-without-pin is intentionally a hard exclusion for `Tools` and
/// friends, but `Compliance` overrides it: compliance probes are
/// read-only protocol checks (initialize, tools/list) whose answers do
/// not depend on the server build, so caching them is safe even without
/// a pin.
pub fn evaluate(input: EligibilityInput<'_>) -> EligibilityDecision {
    let decision = evaluate_inner(input);
    tracing::debug!(
        target: "mcptest_core::cache::eligibility",
        cacheable = decision.cacheable,
        reason = %decision.reason,
        "eligibility decision"
    );
    decision
}

fn evaluate_inner(input: EligibilityInput<'_>) -> EligibilityDecision {
    // A server `cacheScope: no-store` wins over every other
    // signal, including the author's `cache: always`. The author's
    // `cache: never` already returns the same not-cacheable verdict,
    // so we let it take precedence so the reason field is the more
    // specific author-driven one.
    if matches!(input.directive, Some(CacheDirective::Never)) {
        return EligibilityDecision {
            cacheable: false,
            reason: EligibilityReason::DirectiveNever,
        };
    }
    if input
        .server_directive
        .as_ref()
        .map(|d| d.no_store())
        .unwrap_or(false)
    {
        return EligibilityDecision {
            cacheable: false,
            reason: EligibilityReason::ServerNoStore,
        };
    }

    if let Some(reason) = hard_exclusion(&input) {
        return EligibilityDecision {
            cacheable: false,
            reason,
        };
    }

    if matches!(input.directive, Some(CacheDirective::Always)) {
        return EligibilityDecision {
            cacheable: true,
            reason: EligibilityReason::DirectiveAlways,
        };
    }

    type_default(input.kind)
}

/// Apply the hard exclusions in deterministic order.
///
/// Order matters when more than one applies because the reason field
/// surfaces to the reporter. We prefer the most specific reason, which
/// is roughly "what did the author do" (hooks, external) before "what
/// shape is the transport" (HTTP without pin).
fn hard_exclusion(input: &EligibilityInput<'_>) -> Option<EligibilityReason> {
    if input.has_hooks {
        return Some(EligibilityReason::HasHooks);
    }
    if input.effects.iter().any(|e| matches!(e, Effect::External)) {
        return Some(EligibilityReason::EffectExternal);
    }
    if input.transport == Transport::Http
        && !input.server_version_pinned
        && !http_without_pin_exempt(input.kind)
    {
        return Some(EligibilityReason::HttpWithoutServerVersion);
    }
    None
}

/// True when the test kind is exempt from the HTTP-without-pin rule.
///
/// Compliance probes only ask the protocol whether it advertises a set
/// of capabilities. The answer does not depend on which build of the
/// server is on the other end, so the pin is not load-bearing for them.
fn http_without_pin_exempt(kind: TestKind) -> bool {
    matches!(kind, TestKind::Compliance)
}

fn type_default(kind: TestKind) -> EligibilityDecision {
    match kind {
        TestKind::Tools | TestKind::ModelCompatibility => EligibilityDecision {
            cacheable: true,
            reason: EligibilityReason::Default,
        },
        TestKind::Compliance => EligibilityDecision {
            cacheable: true,
            reason: EligibilityReason::DirectiveAlways,
        },
        TestKind::Evals | TestKind::Performance => EligibilityDecision {
            cacheable: false,
            reason: EligibilityReason::TypeDefault(kind),
        },
    }
}

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

    fn base(kind: TestKind) -> EligibilityInput<'static> {
        EligibilityInput {
            kind,
            directive: None,
            effects: &[],
            transport: Transport::Stdio,
            server_version_pinned: false,
            has_hooks: false,
            server_directive: None,
        }
    }

    #[test]
    fn tools_default_is_cacheable() {
        let decision = evaluate(base(TestKind::Tools));
        assert!(decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::Default);
    }

    #[test]
    fn model_compatibility_default_is_cacheable() {
        let decision = evaluate(base(TestKind::ModelCompatibility));
        assert!(decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::Default);
    }

    #[test]
    fn compliance_default_is_cacheable_with_directive_always_reason() {
        let decision = evaluate(base(TestKind::Compliance));
        assert!(decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::DirectiveAlways);
    }

    #[test]
    fn evals_default_is_not_cacheable() {
        let decision = evaluate(base(TestKind::Evals));
        assert!(!decision.cacheable);
        assert_eq!(
            decision.reason,
            EligibilityReason::TypeDefault(TestKind::Evals)
        );
    }

    #[test]
    fn performance_default_is_not_cacheable() {
        let decision = evaluate(base(TestKind::Performance));
        assert!(!decision.cacheable);
        assert_eq!(
            decision.reason,
            EligibilityReason::TypeDefault(TestKind::Performance)
        );
    }

    #[test]
    fn hooks_are_a_hard_exclusion() {
        let mut input = base(TestKind::Tools);
        input.has_hooks = true;
        let decision = evaluate(input);
        assert!(!decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::HasHooks);
    }

    #[test]
    fn http_without_server_version_is_a_hard_exclusion() {
        let mut input = base(TestKind::Tools);
        input.transport = Transport::Http;
        let decision = evaluate(input);
        assert!(!decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::HttpWithoutServerVersion);
    }

    #[test]
    fn http_with_server_version_pin_is_cacheable() {
        let mut input = base(TestKind::Tools);
        input.transport = Transport::Http;
        input.server_version_pinned = true;
        let decision = evaluate(input);
        assert!(decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::Default);
    }

    #[test]
    fn external_effect_is_a_hard_exclusion() {
        let effects = [Effect::External];
        let input = EligibilityInput {
            kind: TestKind::Tools,
            directive: None,
            effects: &effects,
            transport: Transport::Stdio,
            server_version_pinned: false,
            has_hooks: false,
            server_directive: None,
        };
        let decision = evaluate(input);
        assert!(!decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::EffectExternal);
    }

    #[test]
    fn local_and_filesystem_effects_do_not_exclude() {
        let effects = [Effect::Local, Effect::Filesystem];
        let input = EligibilityInput {
            kind: TestKind::Tools,
            directive: None,
            effects: &effects,
            transport: Transport::Stdio,
            server_version_pinned: false,
            has_hooks: false,
            server_directive: None,
        };
        let decision = evaluate(input);
        assert!(decision.cacheable);
    }

    #[test]
    fn directive_never_wins_over_directive_always() {
        // The two directives cannot legally coexist on one field, but a
        // merge from `defaults: cache: always` + per-test `cache: never`
        // would land us in this case. The per-test directive is the one
        // that won the merge.
        let mut input = base(TestKind::Tools);
        input.directive = Some(CacheDirective::Never);
        let decision = evaluate(input);
        assert!(!decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::DirectiveNever);
    }

    #[test]
    fn directive_always_wins_over_type_default_for_evals() {
        let mut input = base(TestKind::Evals);
        input.directive = Some(CacheDirective::Always);
        let decision = evaluate(input);
        assert!(decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::DirectiveAlways);
    }

    #[test]
    fn directive_always_does_not_override_external_effect() {
        let effects = [Effect::External];
        let input = EligibilityInput {
            kind: TestKind::Tools,
            directive: Some(CacheDirective::Always),
            effects: &effects,
            transport: Transport::Stdio,
            server_version_pinned: false,
            has_hooks: false,
            server_directive: None,
        };
        let decision = evaluate(input);
        assert!(!decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::EffectExternal);
    }

    #[test]
    fn directive_always_does_not_override_hooks() {
        let mut input = base(TestKind::Tools);
        input.directive = Some(CacheDirective::Always);
        input.has_hooks = true;
        let decision = evaluate(input);
        assert!(!decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::HasHooks);
    }

    #[test]
    fn directive_auto_falls_through_to_type_default() {
        let mut input = base(TestKind::Performance);
        input.directive = Some(CacheDirective::Auto);
        let decision = evaluate(input);
        assert!(!decision.cacheable);
        assert_eq!(
            decision.reason,
            EligibilityReason::TypeDefault(TestKind::Performance)
        );
    }

    #[test]
    fn compliance_over_http_without_pin_is_cacheable() {
        // Compliance is read-only and protocol-level; the HTTP-without-pin
        // exclusion does not apply.
        let mut input = base(TestKind::Compliance);
        input.transport = Transport::Http;
        let decision = evaluate(input);
        assert!(decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::DirectiveAlways);
    }

    // === : server cache directive ===

    fn no_store() -> super::super::ServerCacheDirective {
        super::super::ServerCacheDirective {
            ttl_ms: None,
            scope: super::super::CacheScope::NoStore,
        }
    }

    fn public_ttl(ms: u64) -> super::super::ServerCacheDirective {
        super::super::ServerCacheDirective {
            ttl_ms: Some(ms),
            scope: super::super::CacheScope::Public,
        }
    }

    #[test]
    fn server_no_store_floors_cacheability_over_directive_always() {
        // The author asked for `cache: always`, but the server says
        // `no-store`. The server wins.
        let mut input = base(TestKind::Tools);
        input.directive = Some(CacheDirective::Always);
        input.server_directive = Some(no_store());
        let decision = evaluate(input);
        assert!(!decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::ServerNoStore);
    }

    #[test]
    fn directive_never_still_wins_over_server_no_store_for_reason_specificity() {
        // Both signal not-cacheable; the author's directive is the more
        // specific reason to surface to the reporter.
        let mut input = base(TestKind::Tools);
        input.directive = Some(CacheDirective::Never);
        input.server_directive = Some(no_store());
        let decision = evaluate(input);
        assert!(!decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::DirectiveNever);
    }

    #[test]
    fn server_public_or_private_scope_does_not_change_default_path() {
        // Advisory scopes (public, private) only affect shared-cache
        // semantics, which v1 does not implement. The eligibility
        // engine treats them like an absent directive.
        let mut input = base(TestKind::Tools);
        input.server_directive = Some(public_ttl(60_000));
        let decision = evaluate(input);
        assert!(decision.cacheable);
        assert_eq!(decision.reason, EligibilityReason::Default);
    }

    #[test]
    fn server_no_store_display_string_is_stable() {
        assert_eq!(
            EligibilityReason::ServerNoStore.to_string(),
            "not cacheable (server set cacheScope: no-store)"
        );
    }

    #[test]
    fn display_strings_are_stable() {
        assert_eq!(
            EligibilityReason::Default.to_string(),
            "cacheable (type default)"
        );
        assert_eq!(
            EligibilityReason::DirectiveAlways.to_string(),
            "cacheable (cache: always)"
        );
        assert_eq!(
            EligibilityReason::DirectiveNever.to_string(),
            "not cacheable (cache: never)"
        );
        assert_eq!(
            EligibilityReason::EffectExternal.to_string(),
            "not cacheable (effect: external declared)"
        );
        assert_eq!(
            EligibilityReason::HasHooks.to_string(),
            "not cacheable (test has hooks)"
        );
        assert_eq!(
            EligibilityReason::HttpWithoutServerVersion.to_string(),
            "not cacheable (HTTP transport without server_version pin)"
        );
        assert_eq!(
            EligibilityReason::TypeDefault(TestKind::Evals).to_string(),
            "not cacheable (test type default for evals)"
        );
        assert_eq!(
            EligibilityReason::TypeDefault(TestKind::Performance).to_string(),
            "not cacheable (test type default for performance)"
        );
    }
}
