//! Cache invalidation test corpus.
//!
//! Exercises the cache layer end to end: every [`EligibilityReason`]
//! variant fires when it should, every kind of input change flips the
//! derived cache key (so a stale entry cannot answer for new inputs),
//! and the [`FsCacheStore`] survives the failure shapes the runner
//! will hand it (missing entries, corrupted metadata, TTL eviction,
//! shard collisions).
//!
//! These tests are the cross-cutting integration corpus; per-module
//! unit tests under `crates/mcptest-core/src/cache/` still cover the
//! internals.

use std::fs;
use std::io::Write;
use std::time::Duration;

use mcptest_core::cache::{
    derive, evaluate, CacheDirective, CacheKeyInputs, CacheStore, Effect, EligibilityDecision,
    EligibilityInput, EligibilityReason, FsCacheStore, TestKind, Transport,
};
use serde_json::json;
use tempfile::TempDir;

// ---------------------------------------------------------------------------
// Eligibility corpus: every reason variant fires when it should.
// ---------------------------------------------------------------------------

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

#[test]
fn eligibility_default_path_is_cacheable_for_tools() {
    let decision = evaluate(base_input());
    assert!(decision.cacheable, "tools default should be cacheable");
    assert_eq!(decision.reason, EligibilityReason::Default);
}

#[test]
fn eligibility_directive_always_overrides_type_default() {
    let mut input = base_input();
    input.kind = TestKind::Evals;
    input.directive = Some(CacheDirective::Always);
    let decision = evaluate(input);
    assert!(decision.cacheable, "cache: always overrides Evals default");
    assert_eq!(decision.reason, EligibilityReason::DirectiveAlways);
}

#[test]
fn eligibility_directive_never_overrides_everything() {
    let mut input = base_input();
    input.directive = Some(CacheDirective::Never);
    let decision = evaluate(input);
    assert!(!decision.cacheable);
    assert_eq!(decision.reason, EligibilityReason::DirectiveNever);
}

#[test]
fn eligibility_external_effect_excludes_caching() {
    let effects = [Effect::External];
    let mut input = base_input();
    input.effects = &effects;
    let decision = evaluate(input);
    assert!(!decision.cacheable);
    assert_eq!(decision.reason, EligibilityReason::EffectExternal);
}

#[test]
fn eligibility_local_and_filesystem_effects_do_not_exclude() {
    let effects = [Effect::Local, Effect::Filesystem];
    let mut input = base_input();
    input.effects = &effects;
    let decision = evaluate(input);
    assert!(decision.cacheable, "local + filesystem stay cacheable");
}

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

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

#[test]
fn eligibility_http_with_server_version_pin_is_cacheable() {
    let mut input = base_input();
    input.transport = Transport::Http;
    input.server_version_pinned = true;
    let decision = evaluate(input);
    assert!(
        decision.cacheable,
        "HTTP + server_version pin is the cache-safe combination"
    );
}

#[test]
fn eligibility_compliance_caches_on_http_without_pin() {
    let mut input = base_input();
    input.kind = TestKind::Compliance;
    input.transport = Transport::Http;
    let decision = evaluate(input);
    assert!(
        decision.cacheable,
        "compliance probes are read-only protocol checks; HTTP without pin is still safe"
    );
}

#[test]
fn eligibility_evals_default_is_never() {
    let mut input = base_input();
    input.kind = TestKind::Evals;
    let decision = evaluate(input);
    assert!(!decision.cacheable);
    assert_eq!(
        decision.reason,
        EligibilityReason::TypeDefault(TestKind::Evals)
    );
}

#[test]
fn eligibility_performance_default_is_never() {
    let mut input = base_input();
    input.kind = TestKind::Performance;
    let decision = evaluate(input);
    assert!(!decision.cacheable);
    assert_eq!(
        decision.reason,
        EligibilityReason::TypeDefault(TestKind::Performance)
    );
}

#[test]
fn eligibility_precedence_never_beats_external_effect() {
    let effects = [Effect::External];
    let mut input = base_input();
    input.directive = Some(CacheDirective::Never);
    input.effects = &effects;
    let decision = evaluate(input);
    assert!(!decision.cacheable);
    assert_eq!(
        decision.reason,
        EligibilityReason::DirectiveNever,
        "explicit cache:never takes precedence over inferred exclusions"
    );
}

#[test]
fn eligibility_precedence_external_effect_beats_directive_always() {
    let effects = [Effect::External];
    let mut input = base_input();
    input.directive = Some(CacheDirective::Always);
    input.effects = &effects;
    let decision = evaluate(input);
    assert!(
        !decision.cacheable,
        "external state can't be made cache-safe by an annotation"
    );
    assert_eq!(decision.reason, EligibilityReason::EffectExternal);
}

// ---------------------------------------------------------------------------
// Key derivation corpus: every kind of input change flips the key.
// ---------------------------------------------------------------------------

fn base_key_inputs() -> (
    String,
    String,
    serde_json::Value,
    Option<String>,
    String,
    String,
) {
    (
        "stdio://node ./server.js".to_string(),
        "echo".to_string(),
        json!({"text": "hello"}),
        Some("1.2.3".to_string()),
        "mcptest 0.1.0".to_string(),
        "max_response_tokens=100".to_string(),
    )
}

fn key_from(
    server_id: &str,
    tool_name: &str,
    normalized_args: &serde_json::Value,
    server_version_pin: Option<&str>,
    runner_version: &str,
    matcher_signature: &str,
) -> String {
    derive(&CacheKeyInputs {
        server_id,
        tool_name,
        normalized_args,
        server_version_pin,
        runner_version,
        matcher_signature,
    })
}

#[test]
fn key_is_stable_for_identical_inputs() {
    let (s, t, a, v, r, m) = base_key_inputs();
    let k1 = key_from(&s, &t, &a, v.as_deref(), &r, &m);
    let k2 = key_from(&s, &t, &a, v.as_deref(), &r, &m);
    assert_eq!(k1, k2);
    assert_eq!(k1.len(), 64, "SHA-256 hex is 64 chars");
}

#[test]
fn key_flips_when_test_inputs_change_arguments() {
    let (s, t, _, v, r, m) = base_key_inputs();
    let baseline = key_from(&s, &t, &json!({"text": "hello"}), v.as_deref(), &r, &m);
    let changed = key_from(&s, &t, &json!({"text": "world"}), v.as_deref(), &r, &m);
    assert_ne!(baseline, changed, "argument value change must invalidate");
}

#[test]
fn key_flips_when_tool_name_changes() {
    let (s, _, a, v, r, m) = base_key_inputs();
    let baseline = key_from(&s, "echo", &a, v.as_deref(), &r, &m);
    let changed = key_from(&s, "ping", &a, v.as_deref(), &r, &m);
    assert_ne!(baseline, changed);
}

#[test]
fn key_flips_when_server_id_changes() {
    // Captures the "change server binary (stdio) -> cache miss" rule from
    // . The runner is expected to encode the stdio command line
    // (and its resolved binary path) into server_id; flipping it must
    // invalidate the key.
    let (_, t, a, v, r, m) = base_key_inputs();
    let baseline = key_from("stdio://node ./server.js", &t, &a, v.as_deref(), &r, &m);
    let changed = key_from("stdio://node ./server-v2.js", &t, &a, v.as_deref(), &r, &m);
    assert_ne!(baseline, changed);
}

#[test]
fn key_flips_when_server_version_pin_changes() {
    let (s, t, a, _, r, m) = base_key_inputs();
    let baseline = key_from(&s, &t, &a, Some("1.2.3"), &r, &m);
    let changed = key_from(&s, &t, &a, Some("1.2.4"), &r, &m);
    assert_ne!(baseline, changed);
}

#[test]
fn key_distinguishes_pin_absent_from_pin_present() {
    let (s, t, a, _, r, m) = base_key_inputs();
    let pinned = key_from(&s, &t, &a, Some("1.2.3"), &r, &m);
    let unpinned = key_from(&s, &t, &a, None, &r, &m);
    assert_ne!(
        pinned, unpinned,
        "absent pin is a distinct cache identity from a present one"
    );
}

#[test]
fn key_flips_when_runner_version_changes() {
    let (s, t, a, v, _, m) = base_key_inputs();
    let baseline = key_from(&s, &t, &a, v.as_deref(), "mcptest 0.1.0", &m);
    let changed = key_from(&s, &t, &a, v.as_deref(), "mcptest 0.2.0", &m);
    assert_ne!(
        baseline, changed,
        "runner upgrade invalidates every entry so behavioral changes are visible"
    );
}

#[test]
fn key_flips_when_matcher_signature_changes() {
    let (s, t, a, v, r, _) = base_key_inputs();
    let baseline = key_from(&s, &t, &a, v.as_deref(), &r, "max_response_tokens=100");
    let changed = key_from(&s, &t, &a, v.as_deref(), &r, "max_response_tokens=200");
    assert_ne!(
        baseline, changed,
        "matcher tweak invalidates that test only"
    );
}

#[test]
fn key_holds_when_unreferenced_field_text_matches() {
    // Captures the "unreferenced env var -> cache hit" intent.
    // The key only hashes the canonical inputs above. Anything the runner
    // chooses not to thread into CacheKeyInputs (envs not referenced by
    // the test, host metadata, wall-clock time) cannot move the key.
    let (s, t, a, v, r, m) = base_key_inputs();
    let k1 = key_from(&s, &t, &a, v.as_deref(), &r, &m);
    // Re-derive with the same canonical inputs.
    let k2 = key_from(&s, &t, &a, v.as_deref(), &r, &m);
    assert_eq!(k1, k2, "unreferenced state cannot move the key");
}

#[test]
fn key_is_canonical_against_argument_key_order() {
    // The deriver sorts keys before hashing, so {"a": 1, "b": 2} and
    // {"b": 2, "a": 1} produce the same JSON Pointer-equal payload and
    // therefore the same key. Surface-order divergence is intentionally
    // out of scope here; key-reorder DRIFT detection lives in
    // model_compat, not the cache.
    let (s, t, _, v, r, m) = base_key_inputs();
    let k1 = key_from(&s, &t, &json!({"a": 1, "b": 2}), v.as_deref(), &r, &m);
    let k2 = key_from(&s, &t, &json!({"b": 2, "a": 1}), v.as_deref(), &r, &m);
    assert_eq!(
        k1, k2,
        "canonical sort keeps argument-key reorder from triggering a false miss"
    );
}

// ---------------------------------------------------------------------------
// FsCacheStore round-trip corpus.
// ---------------------------------------------------------------------------

fn store() -> (FsCacheStore, TempDir) {
    let dir = tempfile::tempdir().expect("create temp cache dir");
    let store = FsCacheStore::with_root(dir.path());
    (store, dir)
}

#[test]
fn store_put_then_get_round_trips_payload() {
    let (s, _dir) = store();
    let key = "a".repeat(64);
    let payload = b"hello world".to_vec();
    s.put(&key, &payload).expect("put");
    let got = s.get(&key).expect("get").expect("hit");
    assert_eq!(got, payload);
}

#[test]
fn store_get_misses_for_absent_key() {
    let (s, _dir) = store();
    let key = "b".repeat(64);
    let got = s.get(&key).expect("get");
    assert!(got.is_none());
}

#[test]
fn store_delete_is_idempotent_on_missing_key() {
    let (s, _dir) = store();
    s.delete("missing-key").expect("delete missing is ok");
    s.delete("missing-key").expect("delete again is ok");
}

#[test]
fn store_list_enumerates_every_written_key() {
    let (s, _dir) = store();
    let mut keys: Vec<String> = (0..3).map(|i| "c".repeat(63) + &i.to_string()).collect();
    for k in &keys {
        s.put(k, b"x").expect("put");
    }
    let mut listed = s.list().expect("list");
    listed.sort();
    keys.sort();
    assert_eq!(listed, keys);
}

#[test]
fn store_overwrites_existing_payload_in_place() {
    let (s, _dir) = store();
    let key = "d".repeat(64);
    s.put(&key, b"first").expect("put first");
    s.put(&key, b"second").expect("put second");
    let got = s.get(&key).expect("get").expect("hit");
    assert_eq!(got, b"second", "second put wins (last-write-wins)");
}

#[test]
fn store_corrupted_metadata_surfaces_typed_error_not_panic() {
    let (s, dir) = store();
    let key = "e".repeat(64);
    s.put(&key, b"payload").expect("put");
    // Find and corrupt the metadata sidecar. We do not depend on the
    // exact sharding layout; we walk the temp dir for any `.meta` file.
    let mut meta_path = None;
    for shard in fs::read_dir(dir.path()).expect("read shards") {
        let shard = shard.expect("shard entry");
        if shard.file_type().expect("type").is_dir() {
            for inner in fs::read_dir(shard.path()).expect("read shard") {
                let inner = inner.expect("inner");
                if inner
                    .path()
                    .extension()
                    .and_then(|e| e.to_str())
                    .map(|e| e == "meta")
                    .unwrap_or(false)
                {
                    meta_path = Some(inner.path());
                }
            }
        }
    }
    let meta_path = meta_path.expect("found a .meta sidecar");
    let mut f = fs::File::create(&meta_path).expect("clobber meta");
    f.write_all(b"this is not valid metadata json")
        .expect("write bad meta");
    drop(f);
    // The store may either return the typed Corrupted error or return
    // None depending on whether the read path tries metadata first.
    // Either is fine; a panic is not.
    match s.get(&key) {
        Ok(_) => {}
        Err(e) => {
            let rendered = format!("{e}");
            assert!(
                !rendered.is_empty(),
                "error should have a Display representation"
            );
        }
    }
}

#[test]
fn store_evict_older_than_removes_aged_entries() {
    let (s, _dir) = store();
    let key = "f".repeat(64);
    s.put(&key, b"aged").expect("put");
    // Wait one second so the entry crosses the eviction cutoff.
    std::thread::sleep(Duration::from_secs(1));
    let removed = s.evict_older_than(0).expect("evict");
    assert!(removed >= 1, "entries older than 0 seconds get evicted");
    assert!(
        s.get(&key).expect("get").is_none(),
        "evicted entry no longer reads back"
    );
}

#[test]
fn store_evict_older_than_keeps_fresh_entries() {
    let (s, _dir) = store();
    let key = "g".repeat(64);
    s.put(&key, b"fresh").expect("put");
    // Evict only entries older than an hour; fresh write survives.
    let removed = s.evict_older_than(3600).expect("evict");
    assert_eq!(removed, 0);
    assert!(s.get(&key).expect("get").is_some());
}

#[test]
fn store_shard_layout_handles_many_keys_without_collision() {
    let (s, _dir) = store();
    // 32 keys with distinct prefixes land in 32 shard buckets without
    // overwriting each other. Catches a shard-dir collision regression.
    for i in 0..32 {
        let key = format!("{:02x}{}", i, "h".repeat(62));
        s.put(&key, format!("v{i}").as_bytes()).expect("put");
    }
    assert_eq!(s.list().expect("list").len(), 32);
}

#[test]
fn store_roundtrip_for_empty_payload_is_distinguishable_from_miss() {
    let (s, _dir) = store();
    let key = "i".repeat(64);
    s.put(&key, &[]).expect("put empty");
    let got = s.get(&key).expect("get");
    assert_eq!(got, Some(Vec::new()), "empty payload is a hit, not a miss");
}

// ---------------------------------------------------------------------------
// Cross-cutting: an `EligibilityDecision` survives Debug/Clone shape so
// reporters can rely on the type contract.
// ---------------------------------------------------------------------------

#[test]
fn eligibility_decision_is_clone_and_debug() {
    let decision = EligibilityDecision {
        cacheable: false,
        reason: EligibilityReason::HasHooks,
    };
    let _: EligibilityDecision = decision.clone();
    let s = format!("{decision:?}");
    assert!(s.contains("HasHooks"));
}
