//! Integration tests for the jury engine dispatch loop.
//!
//! Exercises [`mcptest_core::eval::jury::engine::dispatch_jury`] against a
//! stub `LlmProvider` impl so we can pin the happy path, the split
//! decision, the unanimous fail, cost-budget and consensus early
//! termination, and graceful handling of a malformed juror reply without
//! spinning up real HTTP traffic. The position-swap path is covered with a
//! pairwise configuration that drains two responses from the stub queue per
//! juror. The shared fan-out core
//! ([`mcptest_core::eval::jury::engine::dispatch_votes`], WOR-1308) is
//! exercised directly for budget-driven cancellation.

use std::cell::Cell;
use std::sync::{Arc, Mutex};
use std::time::Duration;

use async_trait::async_trait;

use mcptest_core::eval::cost::CostBudget;
use mcptest_core::eval::jury::bias::BiasMitigations;
use mcptest_core::eval::jury::engine::{
    dispatch_jury, dispatch_votes, ConsensusMethod, DispatchMode, JurorCall, JurorConfig,
    JurorSeat, Vote,
};
use mcptest_core::eval::provider::{
    Capabilities, CompletionOptions, Cost, FinishReason, LlmProvider, Message, ProviderId,
    Response, ResponseContent, Usage,
};
use mcptest_core::eval::ProviderError;
use mcptest_core::report::jury::{JurorStatus, Verdict};

/// Stub provider that pops a canned reply each time `chat_completion` is
/// called. Test code seeds the queue at construction; the dispatcher
/// drains it in order. We hold the queue under a `Mutex` so the trait
/// method (which takes `&self`) can mutate it.
struct StubProvider {
    id: ProviderId,
    replies: Mutex<Vec<Result<Response, ProviderError>>>,
    cost_per_call: f64,
}

impl StubProvider {
    fn new(id: ProviderId, replies: Vec<Result<Response, ProviderError>>) -> Self {
        Self {
            id,
            replies: Mutex::new(replies),
            cost_per_call: 0.0001,
        }
    }
}

#[async_trait]
impl LlmProvider for StubProvider {
    fn name(&self) -> ProviderId {
        self.id
    }

    fn supported_capabilities(&self) -> Capabilities {
        Capabilities::none()
    }

    async fn chat_completion(
        &self,
        _messages: &[Message],
        _options: &CompletionOptions,
    ) -> Result<Response, ProviderError> {
        let mut q = self.replies.lock().expect("stub mutex");
        if q.is_empty() {
            return Err(ProviderError::ProviderInternal(
                "stub queue exhausted".to_owned(),
            ));
        }
        q.remove(0)
    }

    async fn count_tokens(&self, _text: &str, _model: &str) -> Result<u32, ProviderError> {
        Ok(0)
    }

    async fn embed(&self, _text: &str, _model: &str) -> Result<Vec<f32>, ProviderError> {
        Ok(vec![1.0])
    }

    fn estimate_cost(&self, _usage: &Usage, _model: &str) -> Cost {
        Cost {
            input_dollars: self.cost_per_call,
            output_dollars: 0.0,
            cache_read_dollars: None,
            cache_write_dollars: None,
        }
    }
}

fn text_response(json: &str) -> Response {
    Response {
        content: ResponseContent::Text {
            text: json.to_owned(),
        },
        finish_reason: FinishReason::Stop,
        usage: Usage::default(),
        model: "stub".to_owned(),
    }
}

fn pass_reply(score: f64) -> Result<Response, ProviderError> {
    Ok(text_response(&format!(
        r#"{{"verdict": "pass", "score": {score}, "reasoning": "ok"}}"#
    )))
}

fn fail_reply(score: f64) -> Result<Response, ProviderError> {
    Ok(text_response(&format!(
        r#"{{"verdict": "fail", "score": {score}, "reasoning": "bad"}}"#
    )))
}

fn juror_config<'a>(provider_id: ProviderId, provider: &'a StubProvider) -> JurorConfig<'a> {
    JurorConfig {
        provider_id,
        provider,
        model: "stub-1".to_owned(),
        weight: 1.0,
    }
}

#[tokio::test]
async fn three_juror_majority_pass_when_two_pass_one_fails() {
    let p1 = StubProvider::new(ProviderId::Anthropic, vec![pass_reply(0.9)]);
    let p2 = StubProvider::new(ProviderId::Openai, vec![pass_reply(0.8)]);
    let p3 = StubProvider::new(ProviderId::Mistral, vec![fail_reply(0.2)]);
    let jurors = vec![
        juror_config(ProviderId::Anthropic, &p1),
        juror_config(ProviderId::Openai, &p2),
        juror_config(ProviderId::Mistral, &p3),
    ];
    let mut budget = CostBudget::unbounded();
    let mitigations = BiasMitigations::default();
    let result = dispatch_jury(
        &jurors,
        "Be helpful.",
        "The capital of France is Paris.",
        ConsensusMethod::Majority,
        &mut budget,
        &mitigations,
        DispatchMode::Single,
    )
    .await;
    assert_eq!(result.consensus.verdict, Verdict::Pass);
    // All three jurors stay in the report, but the third is cancelled: the
    // first two passes already clinch the majority over a 3-juror panel, so
    // the dispatcher short-circuits the last call.
    assert_eq!(result.consensus.jurors.len(), 3);
    assert_eq!(result.cancelled_jurors, 1);
}

#[tokio::test]
async fn three_juror_split_decision_under_majority() {
    let p1 = StubProvider::new(ProviderId::Anthropic, vec![pass_reply(0.9)]);
    let p2 = StubProvider::new(ProviderId::Openai, vec![fail_reply(0.3)]);
    let p3 = StubProvider::new(ProviderId::Mistral, vec![fail_reply(0.4)]);
    let jurors = vec![
        juror_config(ProviderId::Anthropic, &p1),
        juror_config(ProviderId::Openai, &p2),
        juror_config(ProviderId::Mistral, &p3),
    ];
    let mut budget = CostBudget::unbounded();
    let mitigations = BiasMitigations::default();
    let result = dispatch_jury(
        &jurors,
        "Be accurate.",
        "Some response.",
        ConsensusMethod::Majority,
        &mut budget,
        &mitigations,
        DispatchMode::Single,
    )
    .await;
    assert_eq!(result.consensus.verdict, Verdict::Fail);
}

#[tokio::test]
async fn unanimous_fail_when_every_juror_rejects() {
    let p1 = StubProvider::new(ProviderId::Anthropic, vec![fail_reply(0.2)]);
    let p2 = StubProvider::new(ProviderId::Openai, vec![fail_reply(0.1)]);
    let jurors = vec![
        juror_config(ProviderId::Anthropic, &p1),
        juror_config(ProviderId::Openai, &p2),
    ];
    let mut budget = CostBudget::unbounded();
    let mitigations = BiasMitigations::default();
    let result = dispatch_jury(
        &jurors,
        "Be accurate.",
        "Wrong answer.",
        ConsensusMethod::Unanimous,
        &mut budget,
        &mitigations,
        DispatchMode::Single,
    )
    .await;
    assert_eq!(result.consensus.verdict, Verdict::Fail);
}

#[tokio::test]
async fn cost_budget_terminates_remaining_jurors() {
    // Each call costs 0.0001; cap at 0.00015 means the first call lands,
    // the second sits above the cap and gets cancelled.
    let p1 = StubProvider::new(ProviderId::Anthropic, vec![pass_reply(0.9)]);
    let p2 = StubProvider::new(ProviderId::Openai, vec![pass_reply(0.9)]);
    let p3 = StubProvider::new(ProviderId::Mistral, vec![pass_reply(0.9)]);
    let jurors = vec![
        juror_config(ProviderId::Anthropic, &p1),
        juror_config(ProviderId::Openai, &p2),
        juror_config(ProviderId::Mistral, &p3),
    ];
    let mut budget = CostBudget::with_cap(0.00015);
    let mitigations = BiasMitigations::default();
    let result = dispatch_jury(
        &jurors,
        "Be accurate.",
        "Some response.",
        ConsensusMethod::Majority,
        &mut budget,
        &mitigations,
        DispatchMode::Single,
    )
    .await;
    // First juror lands; second and third are cancelled because the
    // first call already pushed spent above the cap.
    assert!(result.cancelled_jurors >= 1);
    let cancelled = result
        .consensus
        .jurors
        .iter()
        .filter(|j| j.status != JurorStatus::Ok)
        .count();
    assert!(cancelled >= 1);
}

#[tokio::test]
async fn unanimous_terminates_early_on_first_fail() {
    // Unanimous needs every juror to pass, so the first fail decides the
    // verdict and the surviving jurors cannot change it. The later jurors
    // get empty reply queues: if the dispatcher wrongly called them they
    // would hit the "stub queue exhausted" error and surface as Errors
    // rather than the cancelled InvalidRequest we expect.
    let p1 = StubProvider::new(ProviderId::Anthropic, vec![fail_reply(0.2)]);
    let p2 = StubProvider::new(ProviderId::Openai, vec![]);
    let p3 = StubProvider::new(ProviderId::Mistral, vec![]);
    let jurors = vec![
        juror_config(ProviderId::Anthropic, &p1),
        juror_config(ProviderId::Openai, &p2),
        juror_config(ProviderId::Mistral, &p3),
    ];
    let mut budget = CostBudget::unbounded();
    let mitigations = BiasMitigations::default();
    let result = dispatch_jury(
        &jurors,
        "Be accurate.",
        "Wrong answer.",
        ConsensusMethod::Unanimous,
        &mut budget,
        &mitigations,
        DispatchMode::Single,
    )
    .await;
    assert_eq!(result.consensus.verdict, Verdict::Fail);
    // Two jurors were never called.
    assert_eq!(result.cancelled_jurors, 2);
    for juror in result.consensus.jurors.iter().skip(1) {
        assert_eq!(juror.status, JurorStatus::Error);
        let error = juror
            .error
            .as_deref()
            .expect("cancelled juror carries a reason");
        assert!(
            error.contains("consensus already decided"),
            "expected consensus-cancel reason, got: {error}"
        );
    }
}

#[tokio::test]
async fn majority_terminates_early_once_the_verdict_is_clinched() {
    // Majority over five jurors clinches at three passes: passes (3) clear
    // half (2), so the last two jurors cannot flip it. They carry empty
    // queues to prove the dispatcher never called them.
    let p1 = StubProvider::new(ProviderId::Anthropic, vec![pass_reply(0.9)]);
    let p2 = StubProvider::new(ProviderId::Openai, vec![pass_reply(0.9)]);
    let p3 = StubProvider::new(ProviderId::Mistral, vec![pass_reply(0.9)]);
    let p4 = StubProvider::new(ProviderId::Anthropic, vec![]);
    let p5 = StubProvider::new(ProviderId::Openai, vec![]);
    let jurors = vec![
        juror_config(ProviderId::Anthropic, &p1),
        juror_config(ProviderId::Openai, &p2),
        juror_config(ProviderId::Mistral, &p3),
        juror_config(ProviderId::Anthropic, &p4),
        juror_config(ProviderId::Openai, &p5),
    ];
    let mut budget = CostBudget::unbounded();
    let mitigations = BiasMitigations::default();
    let result = dispatch_jury(
        &jurors,
        "Be accurate.",
        "A good answer.",
        ConsensusMethod::Majority,
        &mut budget,
        &mitigations,
        DispatchMode::Single,
    )
    .await;
    assert_eq!(result.consensus.verdict, Verdict::Pass);
    assert_eq!(result.cancelled_jurors, 2);
}

#[tokio::test]
async fn malformed_juror_reply_is_marked_parse_error() {
    let p1 = StubProvider::new(ProviderId::Anthropic, vec![pass_reply(0.9)]);
    let p2 = StubProvider::new(
        ProviderId::Openai,
        vec![Ok(text_response("totally not json"))],
    );
    let jurors = vec![
        juror_config(ProviderId::Anthropic, &p1),
        juror_config(ProviderId::Openai, &p2),
    ];
    let mut budget = CostBudget::unbounded();
    let mitigations = BiasMitigations::default();
    let result = dispatch_jury(
        &jurors,
        "Be accurate.",
        "ok",
        ConsensusMethod::Majority,
        &mut budget,
        &mitigations,
        DispatchMode::Single,
    )
    .await;
    assert_eq!(result.consensus.jurors.len(), 2);
    assert_eq!(result.consensus.jurors[1].status, JurorStatus::ParseError);
    // The remaining successful juror still drives the verdict.
    assert_eq!(result.consensus.verdict, Verdict::Pass);
}

#[tokio::test]
async fn position_swap_applied_for_pairwise_mode() {
    // Pairwise mode with position swap on dispatches two calls per juror,
    // so each stub needs two replies in its queue.
    let p1 = StubProvider::new(
        ProviderId::Anthropic,
        vec![pass_reply(0.9), pass_reply(0.8)],
    );
    let p2 = StubProvider::new(ProviderId::Openai, vec![pass_reply(0.85), pass_reply(0.7)]);
    let jurors = vec![
        juror_config(ProviderId::Anthropic, &p1),
        juror_config(ProviderId::Openai, &p2),
    ];
    let mut budget = CostBudget::unbounded();
    let mitigations = BiasMitigations {
        position_swap: true,
        ..BiasMitigations::default()
    };
    let result = dispatch_jury(
        &jurors,
        "Compare the responses.",
        "Candidate A text",
        ConsensusMethod::Majority,
        &mut budget,
        &mitigations,
        DispatchMode::Pairwise {
            candidate_b: "Candidate B text".to_owned(),
        },
    )
    .await;
    assert!(result.position_swap_applied);
    assert_eq!(result.consensus.verdict, Verdict::Pass);
    // Score should be the average of the two orderings for each juror,
    // averaged again across jurors.
    let score = result.consensus.score.expect("score present");
    assert!(score > 0.7 && score < 0.9);
}

#[tokio::test]
async fn timeout_juror_carries_timeout_status() {
    let p1 = StubProvider::new(ProviderId::Anthropic, vec![pass_reply(0.9)]);
    let p2 = StubProvider::new(
        ProviderId::Openai,
        vec![Err(ProviderError::Timeout(Duration::from_secs(15)))],
    );
    let jurors = vec![
        juror_config(ProviderId::Anthropic, &p1),
        juror_config(ProviderId::Openai, &p2),
    ];
    let mut budget = CostBudget::unbounded();
    let mitigations = BiasMitigations::default();
    let result = dispatch_jury(
        &jurors,
        "Be quick.",
        "ok",
        ConsensusMethod::Majority,
        &mut budget,
        &mitigations,
        DispatchMode::Single,
    )
    .await;
    assert_eq!(result.consensus.jurors[1].status, JurorStatus::Timeout);
}

#[tokio::test]
async fn agreement_metrics_are_attached_to_dispatch_result() {
    let p1 = StubProvider::new(ProviderId::Anthropic, vec![pass_reply(0.9)]);
    let p2 = StubProvider::new(ProviderId::Openai, vec![pass_reply(0.85)]);
    let jurors = vec![
        juror_config(ProviderId::Anthropic, &p1),
        juror_config(ProviderId::Openai, &p2),
    ];
    let mut budget = CostBudget::unbounded();
    let mitigations = BiasMitigations::default();
    let result = dispatch_jury(
        &jurors,
        "Be helpful.",
        "fine response",
        ConsensusMethod::Majority,
        &mut budget,
        &mitigations,
        DispatchMode::Single,
    )
    .await;
    // Two close scores produce high agreement.
    let alpha = result
        .agreement
        .krippendorff_alpha
        .expect("alpha defined for two jurors");
    assert!(alpha > 0.9);
}

#[tokio::test]
async fn budget_exhaustion_cancels_remaining_jurors() {
    // Exercise the shared fan-out core directly: three seats, 0.0001 per
    // call, cap below the first call's spend once it is recorded. The
    // first seat is dispatched; the second and third drain into cancelled
    // votes and their call closure must never run.
    let seats: Vec<JurorSeat> = ["a", "b", "c"]
        .iter()
        .map(|model| JurorSeat {
            provider: ProviderId::Anthropic,
            model: (*model).to_owned(),
            weight: 1.0,
        })
        .collect();
    let mut budget = CostBudget::with_cap(0.00005);
    let calls = Cell::new(0usize);
    let calls_ref = &calls;
    let dispatched = dispatch_votes(&seats, None, &mut budget, |_index| {
        calls_ref.set(calls_ref.get() + 1);
        async move {
            JurorCall {
                result: Ok(Vote {
                    verdict: Verdict::Pass,
                    score: Some(0.9),
                    reasoning: None,
                }),
                cost: Some(Cost {
                    input_dollars: 0.0001,
                    output_dollars: 0.0,
                    cache_read_dollars: None,
                    cache_write_dollars: None,
                }),
                tokens: None,
                latency_ms: 5,
            }
        }
    })
    .await;
    assert_eq!(calls.get(), 1, "only the first juror is dispatched");
    assert_eq!(dispatched.cancelled_jurors, 2);
    assert_eq!(dispatched.votes.len(), 3, "cancelled seats stay reported");
    for vote in &dispatched.votes[1..] {
        let err = vote
            .result
            .as_ref()
            .expect_err("cancelled juror carries an error result");
        assert!(
            err.to_string().contains("cost budget exceeded"),
            "expected budget-cancel reason, got: {err}"
        );
    }
}

/// Stub that records the user message of every call and replies with a
/// fixed passing verdict, for pinning prompt ordering in pairwise mode.
struct UserRecordingStub {
    seen_users: Mutex<Vec<String>>,
}

#[async_trait]
impl LlmProvider for UserRecordingStub {
    fn name(&self) -> ProviderId {
        ProviderId::Anthropic
    }

    fn supported_capabilities(&self) -> Capabilities {
        Capabilities::none()
    }

    async fn chat_completion(
        &self,
        messages: &[Message],
        _options: &CompletionOptions,
    ) -> Result<Response, ProviderError> {
        let user = messages
            .iter()
            .find(|m| m.role == mcptest_core::eval::provider::Role::User)
            .map(|m| m.content.clone())
            .unwrap_or_default();
        self.seen_users.lock().expect("stub mutex").push(user);
        Ok(text_response(r#"{"verdict": "pass", "score": 0.5}"#))
    }

    async fn count_tokens(&self, _text: &str, _model: &str) -> Result<u32, ProviderError> {
        Ok(0)
    }

    async fn embed(&self, _text: &str, _model: &str) -> Result<Vec<f32>, ProviderError> {
        Ok(vec![1.0])
    }

    fn estimate_cost(&self, _usage: &Usage, _model: &str) -> Cost {
        Cost::default()
    }
}

#[tokio::test]
async fn pairwise_dispatch_swaps_candidate_order() {
    // Pairwise mode with the position-swap mitigation must grade the two
    // candidates twice, in opposite slot orders: pass 1 presents (A, B),
    // pass 2 presents (B, A). A no-op swap would leave A before B twice.
    let provider = UserRecordingStub {
        seen_users: Mutex::new(Vec::new()),
    };
    let jurors = vec![JurorConfig {
        provider_id: ProviderId::Anthropic,
        provider: &provider,
        model: "stub-1".to_owned(),
        weight: 1.0,
    }];
    let mut budget = CostBudget::unbounded();
    let mitigations = BiasMitigations {
        position_swap: true,
        ..BiasMitigations::default()
    };
    let result = dispatch_jury(
        &jurors,
        "Compare the responses.",
        "CANDIDATE_A_MARKER",
        ConsensusMethod::Majority,
        &mut budget,
        &mitigations,
        DispatchMode::Pairwise {
            candidate_b: "CANDIDATE_B_MARKER".to_owned(),
        },
    )
    .await;
    assert!(result.position_swap_applied);

    let users = provider.seen_users.lock().expect("stub mutex");
    assert_eq!(users.len(), 2, "pairwise + swap dispatches two calls");
    let first = &users[0];
    let a1 = first.find("CANDIDATE_A_MARKER").expect("A in pass 1");
    let b1 = first.find("CANDIDATE_B_MARKER").expect("B in pass 1");
    assert!(a1 < b1, "pass 1 must present (A, B): {first}");
    let second = &users[1];
    let a2 = second.find("CANDIDATE_A_MARKER").expect("A in pass 2");
    let b2 = second.find("CANDIDATE_B_MARKER").expect("B in pass 2");
    assert!(b2 < a2, "pass 2 must present (B, A): {second}");
}

// Anchor a use of `Arc` so a future contributor who needs a shared
// counter has the import in scope. Today the stub holds replies under
// `Mutex` directly, but real adapters will likely want
// `Arc<Mutex<...>>`.
#[allow(dead_code)]
fn _arc_anchor() -> Arc<Mutex<()>> {
    Arc::new(Mutex::new(()))
}
