//! The engine front-end's juror prompt and reply contract.
//!
//! [`super::engine::dispatch_jury`] asks each juror for a verdict-JSON
//! object (`verdict`, `score`, `reasoning`). This module owns both sides
//! of that contract: the prompt builders that ask for it and the parser
//! that reads it back. It is split out of `engine` so the engine module
//! stays within the size guard and holds only the dispatch and
//! aggregation machinery.
//!
//! Note this contract is distinct from the `llm-judge` prompt template in
//! `crate::matchers::llm_judge` (`pass`, `score`, `reason`, with a parse
//! retry). The `llm-jury` matcher keeps the judge contract for its jurors;
//! only the fan-out loop is shared. See the `engine` module doc (WOR-1308).

use serde::Deserialize;

use crate::eval::error::ProviderError;
use crate::eval::provider::{CompletionOptions, Message, ResponseContent, Role};
use crate::report::jury::Verdict;

use super::engine::Vote;

/// Completion options every engine juror call uses: deterministic
/// temperature and JSON mode, so verdicts are reproducible and parseable.
pub(crate) fn juror_options(model: &str) -> CompletionOptions {
    CompletionOptions {
        model: model.to_owned(),
        temperature: Some(0.0),
        max_tokens: Some(512),
        json_mode: true,
        tools: Vec::new(),
        tool_choice: None,
    }
}

/// Build the single-response grading prompt: one candidate against the
/// rubric, replying with the verdict-JSON object.
pub(crate) fn build_prompt(rubric: &str, response: &str, chain_of_thought: bool) -> Vec<Message> {
    let mut system = String::from(
        "You are an evaluator. Read the rubric and the response, then return a JSON object \
         with fields `verdict` (one of `pass`, `fail`, `abstain`), `score` (a number in [0, 1]), \
         and `reasoning` (a short explanation).",
    );
    if chain_of_thought {
        system.push_str(
            " Think step by step before deciding, but only return the JSON object in your reply.",
        );
    }
    let user = format!(
        "Rubric:\n{rubric}\n\nResponse to evaluate:\n{response}\n\nReply with the JSON object only."
    );
    vec![
        Message {
            role: Role::System,
            content: system,
        },
        Message {
            role: Role::User,
            content: user,
        },
    ]
}

/// Build a pairwise comparison prompt that labels the two candidates A and
/// B in the order they are passed.
///
/// The position-swap mitigation calls this twice with `first` and `second`
/// flipped, so the candidate the juror sees in slot A on one pass appears
/// in slot B on the other. The score field still scores candidate A so the
/// caller can average the two A-scores and read a juror's slot preference
/// off the difference.
pub(crate) fn build_pairwise_prompt(
    rubric: &str,
    first: &str,
    second: &str,
    chain_of_thought: bool,
) -> Vec<Message> {
    let mut system = String::from(
        "You are an evaluator comparing two candidate responses, A and B. Read the rubric and \
         both candidates, then return a JSON object with fields `verdict` (one of `pass`, \
         `fail`, `abstain`) for candidate A, `score` (a number in [0, 1]) for candidate A, and \
         `reasoning` (a short explanation).",
    );
    if chain_of_thought {
        system.push_str(
            " Think step by step before deciding, but only return the JSON object in your reply.",
        );
    }
    let user = format!(
        "Rubric:\n{rubric}\n\nCandidate A:\n{first}\n\nCandidate B:\n{second}\n\nReply with the \
         JSON object only."
    );
    vec![
        Message {
            role: Role::System,
            content: system,
        },
        Message {
            role: Role::User,
            content: user,
        },
    ]
}

#[derive(Debug, Deserialize)]
struct JuryReply {
    verdict: String,
    #[serde(default)]
    score: Option<f64>,
    #[serde(default)]
    reasoning: Option<String>,
}

/// Parse a juror reply into a [`Vote`], rejecting tool-use responses,
/// unknown verdicts, and out-of-range scores so a malformed juror surfaces
/// as a `ParseError` instead of a silently wrong vote.
pub(crate) fn parse_vote(content: &ResponseContent) -> Result<Vote, ProviderError> {
    let text = match content {
        ResponseContent::Text { text } => text.as_str(),
        ResponseContent::ToolUse { .. } => {
            return Err(ProviderError::MalformedResponse(
                "juror returned a tool-use response instead of a JSON verdict".to_owned(),
            ));
        }
    };
    let trimmed = extract_json_object(text).ok_or_else(|| {
        ProviderError::MalformedResponse(format!("no JSON object found in juror reply: {text}"))
    })?;
    let parsed: JuryReply = serde_json::from_str(trimmed).map_err(|e| {
        ProviderError::MalformedResponse(format!("juror reply was not valid JSON: {e}"))
    })?;
    let verdict = match parsed.verdict.trim().to_ascii_lowercase().as_str() {
        "pass" => Verdict::Pass,
        "fail" => Verdict::Fail,
        "abstain" => Verdict::Abstain,
        other => {
            return Err(ProviderError::MalformedResponse(format!(
                "juror reply had unknown verdict `{other}`"
            )));
        }
    };
    if let Some(s) = parsed.score {
        if !s.is_finite() || !(0.0..=1.0).contains(&s) {
            return Err(ProviderError::MalformedResponse(format!(
                "juror score `{s}` outside [0, 1]"
            )));
        }
    }
    Ok(Vote {
        verdict,
        score: parsed.score,
        reasoning: parsed.reasoning,
    })
}

fn extract_json_object(text: &str) -> Option<&str> {
    let start = text.find('{')?;
    let end = text.rfind('}')?;
    if end <= start {
        return None;
    }
    Some(&text[start..=end])
}

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

    #[test]
    fn parse_vote_accepts_clean_pass_json() {
        let content = ResponseContent::Text {
            text: r#"{"verdict": "pass", "score": 0.9, "reasoning": "ok"}"#.to_owned(),
        };
        let vote = parse_vote(&content).expect("valid json parses");
        assert_eq!(vote.verdict, Verdict::Pass);
        assert_eq!(vote.score, Some(0.9));
    }

    #[test]
    fn parse_vote_extracts_json_from_chatty_reply() {
        let content = ResponseContent::Text {
            text: r#"Here is my verdict: {"verdict": "fail", "score": 0.2}"#.to_owned(),
        };
        let vote = parse_vote(&content).expect("embedded json parses");
        assert_eq!(vote.verdict, Verdict::Fail);
    }

    #[test]
    fn parse_vote_rejects_unknown_verdict() {
        let content = ResponseContent::Text {
            text: r#"{"verdict": "maybe", "score": 0.5}"#.to_owned(),
        };
        let err = parse_vote(&content).unwrap_err();
        assert!(matches!(err, ProviderError::MalformedResponse(_)));
    }

    #[test]
    fn parse_vote_rejects_out_of_range_score() {
        let content = ResponseContent::Text {
            text: r#"{"verdict": "pass", "score": 1.7}"#.to_owned(),
        };
        let err = parse_vote(&content).unwrap_err();
        assert!(matches!(err, ProviderError::MalformedResponse(_)));
    }

    #[test]
    fn parse_vote_rejects_garbage() {
        let content = ResponseContent::Text {
            text: "not json at all".to_owned(),
        };
        let err = parse_vote(&content).unwrap_err();
        assert!(matches!(err, ProviderError::MalformedResponse(_)));
    }
}
