//! Verdict-cache wrapper for the live judging paths (WOR-1320).
//!
//! Why this sits at the caller level: the core rubric engine
//! (`mcptest_core::eval::rubric::evaluate_rubric`) stays provider-pure, with
//! no cache hook, so the CLI wraps it here instead. Every judging path that
//! caches (the `mcptest eval` grading runtime, the `run --live` agent `eval:`
//! metrics, and the [`super::eval`] `SingleJudge` runner) goes through this
//! one module, so the hit/miss contract cannot drift: a fresh hit costs
//! nothing and carries a `cached:` prefix on its reasoning, juror errors are
//! never cached, and cache I/O failures degrade to misses.

use std::time::SystemTime;

use mcptest_core::eval::provider::LlmProvider;
use mcptest_core::eval::rubric::{evaluate_rubric, RubricConfig, RubricOutcome, ScoreScale};
use mcptest_core::eval::{
    ProviderError, VerdictCache, VerdictKey, VerdictRecord, DEFAULT_TTL_SECS,
};

/// Tracks mcptest-core's judge `PROMPT_TEMPLATE_VERSION`: the v3 contract
/// added the criteria reply shape (binary criteria decomposition, WOR-1300),
/// so verdicts graded under older contracts must not be served against it.
pub(crate) const JUROR_PROMPT_TEMPLATE_VERSION: &str = "v3";

/// A verdict served from the cache instead of a live judge call.
///
/// Carries only the flat verdict because a [`VerdictRecord`] stores one
/// score and one rationale, not the per-criterion breakdown a fresh
/// [`RubricOutcome`] reports.
pub(crate) struct CachedVerdict {
    /// Score in `[0, 1]` recorded when the verdict was first judged.
    pub(crate) score: f64,
    /// The pass/fail label recorded at store time, so a hit replays the
    /// original gating decision rather than re-deriving it.
    pub(crate) passed: bool,
    /// Rationale prefixed `cached:` so a replayed verdict is
    /// distinguishable from a live judging in any report.
    pub(crate) reasoning: String,
}

/// Outcome of one cache-aware rubric evaluation.
pub(crate) enum RubricGrade {
    /// The judge ran; the full outcome (per-criterion detail, cost) is here.
    Fresh(RubricOutcome),
    /// Served from the cache at zero cost.
    Cached(CachedVerdict),
}

/// Evaluate `config` against `candidate` through the verdict cache.
///
/// The key mirrors [`super::eval`]'s `judge_through_cache` shape (juror
/// model, prompt-template version, criteria, prompt, response) so a juror
/// upgrade, a rubric edit, or a different candidate never collides with old
/// verdicts. A fresh hit is returned at zero cost; a miss judges live and
/// stores the verdict; a juror error returns before any store so a transient
/// provider failure cannot poison later runs. A disabled cache always misses
/// (and never stores), which is exactly what `--no-verdict-cache` buys.
pub(crate) async fn evaluate_rubric_cached(
    config: &RubricConfig,
    candidate: &serde_json::Value,
    provider: &dyn LlmProvider,
    model: &str,
    prompt: &str,
    cache: &mut VerdictCache,
) -> Result<RubricGrade, ProviderError> {
    let criteria = criteria_fingerprint(config);
    let response = candidate.to_string();
    let key = VerdictKey {
        juror_model: model,
        juror_prompt_template_version: JUROR_PROMPT_TEMPLATE_VERSION,
        criteria: &criteria,
        prompt,
        response: &response,
    };
    if let Some(hit) = lookup(cache, &key) {
        return Ok(RubricGrade::Cached(hit));
    }
    let out = evaluate_rubric(config, candidate, provider, model).await?;
    let reasoning = rubric_reasoning(&out, &config.scale);
    store(cache, &key, out.passed, out.score, &reasoning);
    Ok(RubricGrade::Fresh(out))
}

/// Stable serialization of a rubric for the cache key's `criteria` slot.
///
/// `RubricConfig` does not implement `Serialize`, so the `Debug` rendering
/// stands in: it is deterministic within one binary and covers every field.
/// A future struct change merely changes the string, which forces a cache
/// refresh (a miss), never a collision.
fn criteria_fingerprint(config: &RubricConfig) -> String {
    format!("{config:?}")
}

/// Look `key` up, returning a fresh hit or `None`.
///
/// A cache I/O or parse failure is logged and treated as a miss because a
/// corrupt or unreadable cache must never fail the eval itself.
pub(crate) fn lookup(cache: &mut VerdictCache, key: &VerdictKey<'_>) -> Option<CachedVerdict> {
    let record = match cache.get(key) {
        Ok(Some(record)) => record,
        Ok(None) => return None,
        Err(err) => {
            tracing::debug!(target: "mcptest::eval", error = %err, "verdict cache read failed");
            return None;
        }
    };
    let passed = record.verdict == "pass";
    let score = record.score.unwrap_or(0.0);
    let rationale = record.rationale.unwrap_or(record.verdict);
    Some(CachedVerdict {
        score,
        passed,
        reasoning: format!("cached: {rationale}"),
    })
}

/// Store a successful verdict under `key`.
///
/// A write failure is logged and ignored (a full disk must never fail the
/// eval); callers only invoke this after a successful judging, so errors are
/// structurally never cached.
pub(crate) fn store(
    cache: &mut VerdictCache,
    key: &VerdictKey<'_>,
    passed: bool,
    score: f64,
    reasoning: &str,
) {
    let label = if passed { "pass" } else { "fail" };
    let record = VerdictRecord::new(label, DEFAULT_TTL_SECS, SystemTime::now())
        .with_score(score)
        .with_rationale(reasoning.to_owned());
    if let Err(err) = cache.put(key, &record) {
        tracing::debug!(target: "mcptest::eval", error = %err, "verdict cache write failed");
    }
}

/// One-line reasoning string from a rubric outcome for reports and for the
/// cached rationale. When the rubric declares a non-unit `scale`, the
/// native-scale score (for example `4.0/5` or `B`) is appended so a human
/// reads the grade in the units they authored; the normalized `out.score`
/// still drives gating. Lives here so the stored rationale and the displayed
/// reasoning come from one renderer.
pub(crate) fn rubric_reasoning(out: &RubricOutcome, scale: &ScoreScale) -> String {
    let detail = if !out.path.is_empty() {
        let mut s = format!("path: {}", out.path.join(", "));
        if let Some(reason) = &out.leaf_reason {
            s.push_str(&format!(" ({reason})"));
        }
        s
    } else if out.per_criterion.is_empty() {
        format!("score {:.2}", out.score)
    } else {
        out.per_criterion
            .iter()
            .map(|c| match &c.evidence {
                Some(ev) => format!("{}={:.2} (\"{ev}\")", c.name, c.score),
                None => format!("{}={:.2}", c.name, c.score),
            })
            .collect::<Vec<_>>()
            .join(", ")
    };
    match scale {
        ScoreScale::Unit => detail,
        native => format!("{detail} [{}]", native.display(out.score)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcptest_agent::stub::{StubProvider, StubReply};
    use mcptest_core::eval::rubric::Criterion;

    fn rubric() -> RubricConfig {
        RubricConfig {
            criteria: vec![Criterion {
                name: "quality".to_string(),
                weight: 1.0,
                description: "is it good".to_string(),
                ..Criterion::default()
            }],
            threshold: 0.5,
            ..RubricConfig::default()
        }
    }

    fn score_reply(score: f64) -> StubReply {
        StubReply::Text(format!(r#"{{"score": {score}}}"#))
    }

    #[tokio::test]
    async fn warm_cache_skips_the_judge_and_reports_zero_cost() {
        let dir = tempfile::tempdir().unwrap();
        let mut cache = VerdictCache::enabled_at(dir.path());
        let provider = StubProvider::new(vec![score_reply(0.9)]);
        let candidate = serde_json::json!("an answer");

        let first =
            evaluate_rubric_cached(&rubric(), &candidate, &provider, "stub", "p", &mut cache)
                .await
                .unwrap();
        assert!(matches!(first, RubricGrade::Fresh(_)));
        assert_eq!(provider.calls_made(), 1);

        let second =
            evaluate_rubric_cached(&rubric(), &candidate, &provider, "stub", "p", &mut cache)
                .await
                .unwrap();
        assert_eq!(provider.calls_made(), 1, "second run served from the cache");
        let RubricGrade::Cached(hit) = second else {
            panic!("expected a cache hit on the second run");
        };
        assert!(hit.passed);
        assert!((hit.score - 0.9).abs() < 1e-9);
        assert!(
            hit.reasoning.starts_with("cached:"),
            "a hit is visible in the reasoning: {}",
            hit.reasoning
        );
    }

    #[tokio::test]
    async fn disabled_cache_forces_a_fresh_judge_call_every_run() {
        // `--no-verdict-cache` builds a disabled cache (build_verdict_cache);
        // a disabled cache always misses and never stores.
        let mut cache = VerdictCache::disabled();
        let provider = StubProvider::new(vec![score_reply(0.9), score_reply(0.9)]);
        let candidate = serde_json::json!("an answer");

        for _ in 0..2 {
            let grade =
                evaluate_rubric_cached(&rubric(), &candidate, &provider, "stub", "p", &mut cache)
                    .await
                    .unwrap();
            assert!(matches!(grade, RubricGrade::Fresh(_)));
        }
        assert_eq!(
            provider.calls_made(),
            2,
            "with the cache disabled, every run judges afresh"
        );
    }

    #[tokio::test]
    async fn juror_error_is_not_cached() {
        let dir = tempfile::tempdir().unwrap();
        let mut cache = VerdictCache::enabled_at(dir.path());
        let provider =
            StubProvider::new(vec![StubReply::Error("boom".to_string()), score_reply(0.9)]);
        let candidate = serde_json::json!("an answer");

        let err =
            evaluate_rubric_cached(&rubric(), &candidate, &provider, "stub", "p", &mut cache).await;
        assert!(err.is_err(), "the juror error surfaces to the caller");

        // The error was not stored: the next call judges live (and succeeds).
        let second =
            evaluate_rubric_cached(&rubric(), &candidate, &provider, "stub", "p", &mut cache)
                .await
                .unwrap();
        assert!(matches!(second, RubricGrade::Fresh(_)));

        // The success was stored: the third call is a hit.
        let third =
            evaluate_rubric_cached(&rubric(), &candidate, &provider, "stub", "p", &mut cache)
                .await
                .unwrap();
        assert!(matches!(third, RubricGrade::Cached(_)));
    }
}
