//! Jury analytics as assertion targets.
//!
//! The `llm-jury` matcher computes inter-juror agreement, a confidence band,
//! and an escalation flag on every run. Before  those were only
//! printed in the verdict reason. This module turns them into assertable
//! `target`s (`jury.agreement`, `jury.confidence`, `jury.escalate`) resolved
//! against a per-test metrics object instead of the response, so a test can
//! gate on the jury that runs inside it without a new matcher.

use serde_json::{json, Value};

use super::judge_matcher::check_llm_judge;
use super::{
    apply_assertion_transform, check_citation, check_llm_jury, check_matcher, check_similar,
    check_snapshot, resolve_target, SnapshotContext,
};
use crate::eval::jury::agreement::ConfidenceBand;
use crate::eval::provider::LlmProvider;
use crate::matchers::llm_jury::{self, JuryMetrics};
use crate::suite::{Assertion, Matcher};
use crate::transform::TransformContext;

/// Prefix that marks a target as a jury-analytics target rather than a path
/// into the response. The runner routes any `jury.*` target to the per-test
/// jury metrics object built by [`metrics_object`].
pub const JURY_PREFIX: &str = "jury.";

/// True when `target` addresses jury analytics rather than the response.
///
/// Used by the evaluator to pick the resolution root: a `jury.*` target
/// resolves against the jury metrics, everything else against the response.
pub fn is_jury_target(target: &str) -> bool {
    target == "jury" || target.starts_with(JURY_PREFIX)
}

/// Build the per-test jury metrics object that `jury.*` targets resolve
/// against.
///
/// The shape is `{ "jury": { "agreement": <number|null>, "confidence":
/// "high|medium|low", "escalate": <bool>, "same_family": <bool>,
/// "bias_warning": <string|null>, "style_drift": <number|null> } }` so the
/// existing dot-path resolver walks `jury.agreement` and friends directly.
/// `agreement` is `null` for a single-juror jury, which the resolver surfaces
/// and the evaluator treats as a skip-with-note. `bias_warning` is `null` when
/// no warning fired; `style_drift` is `null` when the style-swap probe was off.
pub fn metrics_object(metrics: &JuryMetrics) -> Value {
    json!({
        "jury": {
            "agreement": metrics.agreement,
            "confidence": confidence_str(metrics.confidence),
            "escalate": metrics.escalate,
            "same_family": metrics.same_family,
            "bias_warning": metrics.bias_warning,
            "style_drift": metrics.style_drift,
        }
    })
}

/// Lowercase label for a confidence band, matching the `jury.confidence`
/// target values documented for users (`high`, `medium`, `low`).
fn confidence_str(band: ConfidenceBand) -> &'static str {
    match band {
        ConfidenceBand::High => "high",
        ConfidenceBand::Medium => "medium",
        ConfidenceBand::Low => "low",
    }
}

/// Outcome of evaluating one assertion ( extends the old
/// pass/fail-only result with a skip state).
///
/// `Skipped` exists for the one case the spec calls out: a `jury.agreement`
/// assertion on a single-juror jury, where alpha is undefined. A skip neither
/// passes nor fails the assertion; it carries a note so the reader sees why
/// the gate did not apply, and the score model treats it as a non-failing,
/// non-scoring item.
pub(super) enum AssertionEval {
    /// The matcher ran and the assertion held.
    Pass,
    /// The matcher ran and the assertion did not hold.
    Fail(String),
    /// The assertion did not apply and was skipped with a reason.
    Skipped(String),
}

/// One run of the single `llm-jury` assertion in a test.
///
/// Bundles the assertion's pass/fail (so the score model judges it like any
/// other) with the engine-computed [`JuryMetrics`] (so the `jury.*` targets
/// resolve against the same run). Running the jury once and sharing this
/// struct keeps the verdict and the analytics from ever coming from two
/// separate jury dispatches.
pub(super) struct JuryRun {
    /// `Ok(())` when the jury passed its quorum; `Err(message)` otherwise.
    /// Folded into the jury assertion's scored item exactly as the inline
    /// matcher result was before .
    pub(super) outcome: Result<(), String>,
    /// The agreement, confidence band, and escalation flag the jury computed.
    /// Resolved against by `jury.*` targets.
    pub(super) metrics: JuryMetrics,
    /// The reporter-facing jury consensus built from the same votes (WOR-1384),
    /// folded onto `TestResult.consensus` after the run so the per-juror
    /// breakdown renders. `None` when the jury could not run (no provider, a
    /// transform failure on the target).
    pub(super) consensus: Option<crate::report::JuryConsensus>,
}

/// Run the test's single `llm-jury` assertion once and return both its
/// verdict and its analytics.
///
/// `actual` is the value resolved from the jury assertion's own target (for
/// example `result.content[0].text`); the jury grades that value. The caller
/// has already established there is exactly one jury assertion in the test.
pub(super) async fn run_llm_jury(
    actual: &Value,
    raw: &Value,
    provider: Option<&dyn LlmProvider>,
    generator_model: Option<&str>,
) -> Result<JuryRun, String> {
    let Some(provider) = provider else {
        return Err(
            "matcher `llm-jury` requires an LlmProvider; build McpExecutor with `with_provider`"
                .to_string(),
        );
    };
    let config = llm_jury::parse_config(raw).map_err(|e| e.to_string())?;
    // v1 closer dispatches every juror through the same provider; per-model
    // routing arrives with the real Anthropic/OpenAI/Gemini drivers.
    let providers: Vec<&dyn LlmProvider> =
        std::iter::repeat_n(provider, config.jurors.len()).collect();
    let (outcome, metrics, consensus) =
        llm_jury::evaluate_with_metrics(&config, actual, &providers, generator_model)
            .await
            .map_err(|e| format!("llm-jury provider error: {e}"))?;
    let verdict = if outcome.pass {
        Ok(())
    } else {
        let summary = outcome
            .diffs
            .first()
            .and_then(|d| d.note.clone())
            .unwrap_or_else(|| "jury rejected the candidate".to_string());
        Err(format!("llm-jury failed: {summary}"))
    };
    Ok(JuryRun {
        outcome: verdict,
        metrics,
        consensus: Some(consensus),
    })
}

/// Run the test's single `llm-jury` assertion once, returning its position
/// and the [`JuryRun`] when the test has one.
///
/// Returns `None` when the test declares no `llm-jury` assertion. The
/// load-time check guarantees at most one when a `jury.*` target is present,
/// so this pre-pass walks for the first jury assertion and runs it. A jury
/// that errors (no provider, provider failure) yields a `JuryRun` whose
/// `outcome` carries the error, so the jury assertion still fails cleanly and
/// the `jury.*` targets see a defined (low-confidence) fallback.
pub(super) async fn prepare_jury(
    response: &Value,
    expectations: &[Assertion],
    provider: Option<&dyn LlmProvider>,
    ctx: &TransformContext,
    generator_model: Option<&str>,
) -> Option<(usize, JuryRun)> {
    let (index, assertion) = expectations
        .iter()
        .enumerate()
        .find(|(_, a)| matches!(a.matcher, Matcher::LlmJury(_)))?;
    let Matcher::LlmJury(raw) = &assertion.matcher else {
        return None;
    };
    let raw_target = resolve_target(response, &assertion.target);
    let target = match apply_assertion_transform(raw_target, assertion, ctx).await {
        Ok(value) => value,
        // A transform failure on the jury target fails the jury assertion and
        // leaves the analytics undefined; report low confidence so a
        // `jury.escalate` target reads true rather than panicking.
        Err(message) => return Some((index, jury_error_run(message))),
    };
    match run_llm_jury(&target, raw, provider, generator_model).await {
        Ok(run) => Some((index, run)),
        Err(message) => Some((index, jury_error_run(message))),
    }
}

/// Build a [`JuryRun`] for a jury that could not produce analytics (no
/// provider, a transform failure, a provider error). The verdict carries the
/// error and the metrics report low confidence so the `jury.*` targets stay
/// defined and a `jury.escalate` reads true.
fn jury_error_run(message: String) -> JuryRun {
    JuryRun {
        outcome: Err(message),
        metrics: JuryMetrics {
            agreement: None,
            confidence: ConfidenceBand::Low,
            escalate: true,
            // No jury ran, so there is no juror-vs-generator family to compare
            // and no agreement to fold a warning from.
            same_family: false,
            bias_warning: None,
            // No jury ran, so the style-swap probe never executed.
            style_drift: None,
        },
        // No jury ran, so there is no per-juror consensus to attach.
        consensus: None,
    }
}

/// Evaluate one assertion against the response (or, for a `jury.*` target,
/// against the per-test jury metrics object).
///
/// `jury_obj` carries the metrics object the single `llm-jury` assertion
/// produced, when the test has one. A `jury.*` target resolves against it
/// instead of the response; a `jury.agreement` target that resolves to null
/// (a single-juror jury) is skipped with a note rather than matched. When the
/// assertion carries a `transform:`, it runs on the extracted value
/// before any matcher sees it. `subject_model` is the model under test when
/// the caller knows it, so the judge can flag a same-family verdict
/// (WOR-1299).
#[allow(clippy::too_many_arguments)]
pub(super) async fn eval_assertion(
    response: &Value,
    assertion: &Assertion,
    provider: Option<&dyn LlmProvider>,
    default_judge_model: Option<&str>,
    ctx: &TransformContext,
    jury_obj: Option<&Value>,
    snapshot: &SnapshotContext,
    subject_model: Option<&str>,
) -> AssertionEval {
    let raw_target = if is_jury_target(&assertion.target) {
        match jury_obj {
            Some(metrics) => resolve_target(metrics, &assertion.target),
            // A `jury.*` target with no jury in the test is a load error
            // caught upstream; reaching here is a programmer error, so fail
            // loudly rather than silently passing.
            None => {
                return AssertionEval::Fail(format!(
                    "target `{}` references jury analytics but the test ran no jury",
                    assertion.target
                ))
            }
        }
    } else {
        resolve_target(response, &assertion.target)
    };
    // A null `jury.agreement` means a single-juror jury (alpha undefined);
    // skip the assertion with a note instead of matching null.
    if assertion.target == "jury.agreement" && raw_target.is_null() {
        return AssertionEval::Skipped(
            "jury.agreement is null (single-juror jury, agreement undefined); assertion skipped"
                .to_string(),
        );
    }
    let target = match apply_assertion_transform(raw_target, assertion, ctx).await {
        Ok(value) => value,
        Err(message) => return AssertionEval::Fail(message),
    };
    let result = match &assertion.matcher {
        Matcher::LlmJury(value) => check_llm_jury(&target, value, provider).await,
        Matcher::LlmJudge(value) => {
            check_llm_judge(&target, value, provider, default_judge_model, subject_model).await
        }
        Matcher::Similar(value) => check_similar(&target, value, provider).await,
        Matcher::Citation(value) => check_citation(&target, value, provider).await,
        Matcher::Snapshot(name) => check_snapshot(&target, name, snapshot),
        other => check_matcher(&target, other),
    };
    match result {
        Ok(()) => AssertionEval::Pass,
        Err(message) => {
            // When the assertion failed because its target path does not exist
            // on the actual shape (a typo, or `isError` vs `is_error`), append
            // a hint so the operator sees "did not resolve" instead of a bare
            // "got null". Purely additive: it never changes pass/fail.
            let base = if is_jury_target(&assertion.target) {
                jury_obj
            } else {
                Some(response)
            };
            let enriched = base
                .and_then(|b| crate::executor::absent_target_hint(b, &assertion.target))
                .map_or(message.clone(), |hint| format!("{message} ({hint})"));
            AssertionEval::Fail(enriched)
        }
    }
}

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

    fn metrics(agreement: Option<f64>, confidence: ConfidenceBand, escalate: bool) -> JuryMetrics {
        JuryMetrics {
            agreement,
            confidence,
            escalate,
            same_family: false,
            bias_warning: None,
            style_drift: None,
        }
    }

    #[test]
    fn jury_prefix_is_recognized_but_other_targets_are_not() {
        assert!(is_jury_target("jury.agreement"));
        assert!(is_jury_target("jury.confidence"));
        assert!(is_jury_target("jury.escalate"));
        assert!(is_jury_target("jury"));
        assert!(!is_jury_target("result.content[0].text"));
        assert!(!is_jury_target("juryxyz"));
    }

    #[test]
    fn agreement_resolves_as_a_number_when_alpha_is_defined() {
        let obj = metrics_object(&metrics(Some(0.82), ConfidenceBand::High, false));
        assert_eq!(resolve_target(&obj, "jury.agreement"), json!(0.82));
        assert_eq!(resolve_target(&obj, "jury.confidence"), json!("high"));
        assert_eq!(resolve_target(&obj, "jury.escalate"), json!(false));
    }

    #[test]
    fn agreement_resolves_to_null_for_single_juror_jury() {
        let obj = metrics_object(&metrics(None, ConfidenceBand::Low, true));
        assert_eq!(resolve_target(&obj, "jury.agreement"), Value::Null);
        assert_eq!(resolve_target(&obj, "jury.confidence"), json!("low"));
        assert_eq!(resolve_target(&obj, "jury.escalate"), json!(true));
    }

    #[test]
    fn bias_targets_resolve_off_the_metrics_object() {
        // A same-family juror on a low-agreement run carries both a true
        // same_family flag and a non-null bias_warning.
        let m = JuryMetrics {
            agreement: Some(0.4),
            confidence: ConfidenceBand::Low,
            escalate: true,
            same_family: true,
            bias_warning: Some("low agreement with bias signal".to_string()),
            style_drift: None,
        };
        let obj = metrics_object(&m);
        assert_eq!(resolve_target(&obj, "jury.same_family"), json!(true));
        assert!(resolve_target(&obj, "jury.bias_warning").is_string());
    }

    #[test]
    fn bias_warning_is_null_when_no_warning_fired() {
        let obj = metrics_object(&metrics(Some(0.9), ConfidenceBand::High, false));
        assert_eq!(resolve_target(&obj, "jury.same_family"), json!(false));
        assert_eq!(resolve_target(&obj, "jury.bias_warning"), Value::Null);
    }

    #[test]
    fn style_drift_resolves_as_a_number_when_the_probe_ran() {
        // WOR-1370: with style swap on, the folded drift is an assertable
        // numeric target; with it off it resolves to null.
        let mut m = metrics(Some(0.9), ConfidenceBand::High, false);
        m.style_drift = Some(0.3);
        let obj = metrics_object(&m);
        assert_eq!(resolve_target(&obj, "jury.style_drift"), json!(0.3));
    }

    #[test]
    fn style_drift_is_null_when_the_probe_was_off() {
        let obj = metrics_object(&metrics(Some(0.9), ConfidenceBand::High, false));
        assert_eq!(resolve_target(&obj, "jury.style_drift"), Value::Null);
    }
}
