//! LLM-judge consensus and per-juror verdict types.
//!
//! These types ride on the canonical report so the pretty and JSON
//! reporters can surface per-juror reasoning, score, cost, and latency
//! when an `llm-judge` matcher decides a test. The runner wiring lands
//! later; this module ships the model and is consumed by the reporters
//! today so format work can land independently of the W7 jury engine.

use serde::{Deserialize, Serialize};

use crate::eval::jury::agreement::ConfidenceBand;
use crate::redaction::Redactor;

/// Aggregate verdict across a jury of model jurors.
///
/// Reporters render the consensus line collapsed by default and expand
/// the per-juror breakdown under a verbosity flag (`--show-juror-details`
/// in the CLI). Costs roll up so a reader can see the cost of a single
/// llm-judge decision at a glance.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct JuryConsensus {
    /// Final verdict the jury settled on after the consensus method
    /// (majority, unanimous, weighted, etc.) was applied.
    pub verdict: Verdict,
    /// Optional human-readable name of the consensus method that the
    /// runner used. Surfaced in the pretty reporter so a reader can tell
    /// "5 of 7 voted pass under majority" apart from "1 of 7 vetoed
    /// under unanimous".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub method: Option<String>,
    /// Optional aggregate confidence score in [0.0, 1.0]. Reporters
    /// surface this verbatim, no rounding.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub score: Option<f64>,
    /// Sum of `cost_usd` across jurors that completed. Skipped when no
    /// juror reported a cost.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub total_cost_usd: Option<f64>,
    /// Confidence band derived from inter-juror agreement.
    ///
    /// High agreement reports `High`, tentative `Medium`, low `Low`. It
    /// is a relabel of the agreement interpretation (ADR 0030), not a new
    /// statistic. `None` when the runner did not compute agreement (for
    /// example a single-juror verdict or an older serialized report), so
    /// existing output paths keep parsing.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub confidence: Option<ConfidenceBand>,
    /// True when the jury disagreed badly enough to escalate to a human.
    ///
    /// Set when agreement is `LowAgreement` (below the 0.667 threshold
    /// from ADR 0030). The runner marks the verdict for review rather
    /// than silently reporting an averaged pass or fail. Defaults to
    /// `false` so older reports and verdicts without agreement data
    /// deserialize unchanged.
    #[serde(default, skip_serializing_if = "is_false")]
    pub escalate_for_review: bool,
    /// Per-juror details in deterministic order. Empty when no juror
    /// data is available (the runner records the verdict but withholds
    /// individual votes).
    #[serde(default)]
    pub jurors: Vec<Juror>,
}

/// Skip the escalation flag in serialized output when it is `false` so
/// the common (non-escalated) verdict serializes to the pre-
/// shape.
fn is_false(value: &bool) -> bool {
    !*value
}

/// Token counts the provider reported for one juror call, carried so cost
/// tooling has the quantities behind `cost_usd` (per-token accounting and
/// prompt-cache hit rate), not just the dollar total.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct JurorTokens {
    /// Input (prompt) tokens billed for this juror call.
    pub input: u32,
    /// Output (completion) tokens billed for this juror call.
    pub output: u32,
    /// Tokens served from the provider-side prompt cache, when reported.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cache_read: Option<u32>,
    /// Tokens written into the provider-side prompt cache, when reported.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cache_write: Option<u32>,
}

/// One juror entry in a [`JuryConsensus`].
///
/// Carries the verdict, score, reasoning, cost, and latency for a single
/// model invocation. Failed jurors carry an error message and a status
/// distinct from `Ok` so reporters can surface "timed out" or "rate
/// limited" without parsing free text.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Juror {
    /// Model identifier (for example `gpt-4o-mini`, `claude-haiku-4`).
    pub model: String,
    /// Provider name (for example `openai`, `anthropic`).
    pub provider: String,
    /// Verdict cast by this juror.
    pub verdict: Verdict,
    /// Confidence in [0.0, 1.0]. Optional because some judges return a
    /// verdict without a structured score.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub score: Option<f64>,
    /// Free-text reasoning from the model, verbatim. The pretty reporter
    /// truncates to 500 chars; the JSON reporter emits the full string.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reasoning: Option<String>,
    /// USD cost reported by the provider, when available.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cost_usd: Option<f64>,
    /// Token counts behind `cost_usd`, when the provider reported usage.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tokens: Option<JurorTokens>,
    /// Wall-clock latency in milliseconds for this juror's call.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub latency_ms: Option<u64>,
    /// Error message when the juror's call failed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    /// Outcome of the juror's call.
    pub status: JurorStatus,
}

/// Per-juror call outcome distinct from the verdict.
///
/// `Ok` means the juror returned a usable verdict. The other variants
/// flag a failure mode so the reporter can render the right banner
/// without parsing the error string.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum JurorStatus {
    /// Juror returned a verdict normally.
    Ok,
    /// Juror timed out before returning a verdict.
    Timeout,
    /// Provider rate-limited the request.
    RateLimit,
    /// Juror returned a response we could not parse into a verdict.
    ParseError,
    /// Any other error reported by the provider.
    Error,
}

/// Verdict cast by a juror or returned by the consensus.
///
/// Kept as a small kebab-case enum so JSON consumers can match on it
/// without dealing with free text.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Verdict {
    /// The juror accepts the response (test passes).
    Pass,
    /// The juror rejects the response (test fails).
    Fail,
    /// The juror abstains; the consensus method decides how to weigh
    /// the abstention.
    Abstain,
}

impl JuryConsensus {
    /// Return a copy of the consensus with redactable strings run
    /// through `redactor`. Pure data move, fast enough to do at the
    /// reporter dispatch site.
    pub fn redacted(&self, redactor: &Redactor) -> Self {
        Self {
            verdict: self.verdict,
            method: self.method.clone(),
            score: self.score,
            total_cost_usd: self.total_cost_usd,
            confidence: self.confidence,
            escalate_for_review: self.escalate_for_review,
            jurors: self.jurors.iter().map(|j| j.redacted(redactor)).collect(),
        }
    }
}

impl Juror {
    /// Return a copy of the juror with `reasoning` and `error` redacted.
    pub fn redacted(&self, redactor: &Redactor) -> Self {
        Self {
            model: self.model.clone(),
            provider: self.provider.clone(),
            verdict: self.verdict,
            score: self.score,
            reasoning: self
                .reasoning
                .as_ref()
                .map(|s| redactor.redact_value(s).into_owned()),
            cost_usd: self.cost_usd,
            // Token counts are not secret; carry them through unchanged.
            tokens: self.tokens.clone(),
            latency_ms: self.latency_ms,
            error: self
                .error
                .as_ref()
                .map(|s| redactor.redact_value(s).into_owned()),
            status: self.status,
        }
    }
}

impl JuryConsensus {
    /// Build a 3-juror sample for reporter tests. Two jurors pass, one
    /// fails with a timeout. Cost rolls up across the two completed
    /// jurors; the timed-out juror reports neither cost nor reasoning.
    pub fn sample() -> Self {
        Self {
            verdict: Verdict::Pass,
            method: Some("majority".into()),
            score: Some(0.75),
            total_cost_usd: Some(0.00021),
            confidence: Some(ConfidenceBand::High),
            escalate_for_review: false,
            jurors: vec![
                Juror {
                    model: "gpt-4o-mini".into(),
                    provider: "openai".into(),
                    verdict: Verdict::Pass,
                    score: Some(0.9),
                    reasoning: Some(
                        "Response cites at least one supporting fact and stays on topic.".into(),
                    ),
                    cost_usd: Some(0.00011),
                    tokens: Some(JurorTokens {
                        input: 820,
                        output: 96,
                        cache_read: Some(512),
                        cache_write: None,
                    }),
                    latency_ms: Some(820),
                    error: None,
                    status: JurorStatus::Ok,
                },
                Juror {
                    model: "claude-haiku-4".into(),
                    provider: "anthropic".into(),
                    verdict: Verdict::Pass,
                    score: Some(0.8),
                    reasoning: Some(
                        "Tool call returns a valid issue id and matches schema.".into(),
                    ),
                    cost_usd: Some(0.00010),
                    tokens: Some(JurorTokens {
                        input: 640,
                        output: 80,
                        cache_read: None,
                        cache_write: None,
                    }),
                    latency_ms: Some(640),
                    error: None,
                    status: JurorStatus::Ok,
                },
                Juror {
                    model: "gemini-1.5-flash".into(),
                    provider: "google".into(),
                    verdict: Verdict::Abstain,
                    score: None,
                    reasoning: None,
                    cost_usd: None,
                    tokens: None,
                    latency_ms: Some(15000),
                    error: Some("request timed out after 15s".into()),
                    status: JurorStatus::Timeout,
                },
            ],
        }
    }
}

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

    #[test]
    fn sample_consensus_round_trips_via_json() {
        let c = JuryConsensus::sample();
        let j = serde_json::to_string(&c).unwrap();
        let back: JuryConsensus = serde_json::from_str(&j).unwrap();
        assert_eq!(c, back);
    }

    #[test]
    fn verdict_serializes_kebab_case() {
        let v = serde_json::to_value(Verdict::Pass).unwrap();
        assert_eq!(v, serde_json::json!("pass"));
        let v = serde_json::to_value(Verdict::Fail).unwrap();
        assert_eq!(v, serde_json::json!("fail"));
        let v = serde_json::to_value(Verdict::Abstain).unwrap();
        assert_eq!(v, serde_json::json!("abstain"));
    }

    #[test]
    fn juror_status_serializes_kebab_case() {
        let v = serde_json::to_value(JurorStatus::RateLimit).unwrap();
        assert_eq!(v, serde_json::json!("rate-limit"));
        let v = serde_json::to_value(JurorStatus::ParseError).unwrap();
        assert_eq!(v, serde_json::json!("parse-error"));
    }

    #[test]
    fn redacted_walks_reasoning_and_error() {
        let mut c = JuryConsensus::sample();
        c.jurors[0].reasoning = Some("ghp_abcdefghijklmnop1234".into());
        c.jurors[2].error = Some("ghp_abcdefghijklmnop1234".into());
        let r = c.redacted(&Redactor::new());
        assert_eq!(r.jurors[0].reasoning.as_deref(), Some("[REDACTED:token]"));
        assert_eq!(r.jurors[2].error.as_deref(), Some("[REDACTED:token]"));
    }

    #[test]
    fn sample_has_three_jurors_with_one_timeout() {
        let c = JuryConsensus::sample();
        assert_eq!(c.jurors.len(), 3);
        assert_eq!(c.jurors[2].status, JurorStatus::Timeout);
        assert_eq!(c.verdict, Verdict::Pass);
    }

    #[test]
    fn juror_tokens_round_trip_through_serde() {
        let juror = JuryConsensus::sample().jurors[0].clone();
        assert!(juror.tokens.is_some(), "sample juror 0 carries token usage");
        let json = serde_json::to_string(&juror).expect("serialize");
        let back: Juror = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back.tokens, juror.tokens);
    }

    #[test]
    fn juror_without_tokens_omits_the_field() {
        // The timed-out juror never reported usage, so the field must not
        // appear in the JSON, keeping reports without usage byte-stable.
        let juror = JuryConsensus::sample().jurors[2].clone();
        assert!(juror.tokens.is_none(), "the timed-out juror has no tokens");
        let json = serde_json::to_string(&juror).expect("serialize");
        assert!(
            !json.contains("tokens"),
            "absent tokens must not serialize: {json}"
        );
    }
}
