//! Jury engine: vote aggregation plus the shared per-juror dispatch loop.
//!
//! Two layers live here. [`evaluate_with_jury`] folds pre-computed
//! [`JurorVote`]s into a [`JuryConsensus`], threading the agreement
//! metrics from [`crate::eval::jury::agreement`] so a single call site
//! produces everything the reporter needs. [`dispatch_votes`] is the one
//! per-juror fan-out loop (the WOR-1308 convergence): per-juror error
//! isolation, [`CostBudget`] enforcement with cancellation draining, and
//! optional consensus short-circuiting, with the actual juror call
//! supplied by the front-end.
//!
//! Two front-ends share that loop rather than one absorbing the other:
//! [`dispatch_jury`] owns the engine's verdict-JSON prompt contract (see
//! the sibling `prompt` module) and the pairwise
//! [`DispatchMode`] with its position swap, while the `llm-jury` matcher
//! (`crate::matchers::llm_jury`) keeps the judge prompt contract its
//! jurors already speak (parse retry, per-juror thresholds, provenance
//! notes) and its own aggregation (weighted quorum, `min_agreement`, bias
//! mitigations) on top of the votes the loop returns. Folding the matcher
//! onto the engine prompt would have silently changed every existing
//! jury's reply contract, so the convergence is the machinery, not the
//! prompts.

use std::cell::Cell;
use std::future::Future;
use std::time::Instant;

use crate::eval::cost::{ConsensusTally, CostBudget};
use crate::eval::error::ProviderError;
use crate::eval::jury::agreement::{compute_agreement, Agreement};
use crate::eval::jury::bias::BiasMitigations;
use crate::eval::provider::{Cost, LlmProvider, Message, ProviderId};
use crate::report::jury::{Juror, JurorStatus, JurorTokens, JuryConsensus, Verdict};

use super::prompt::{build_pairwise_prompt, build_prompt, juror_options, parse_vote};

/// Pre-computed juror result the runner hands to [`evaluate_with_jury`].
///
/// Why this shape: the aggregation is transport-free, so the dispatch loop
/// (or a test) is responsible for producing one [`JurorVote`] per
/// attempted juror, with success and failure expressed by `result`.
/// Mapping `Result<Vote, ProviderError>` onto [`JurorStatus`] keeps the
/// failure taxonomy aligned with the provider trait instead of carrying
/// free-text statuses.
#[derive(Debug)]
pub struct JurorVote {
    /// Provider that produced this juror.
    pub provider: ProviderId,
    /// Model identifier (provider-specific).
    pub model: String,
    /// Wall-clock latency in milliseconds.
    pub latency_ms: u64,
    /// USD cost for this juror call, when reported by the provider.
    pub cost_usd: Option<f64>,
    /// Token counts behind `cost_usd`, when the provider reported usage.
    /// Summed across the swap pass in pairwise mode, mirroring the cost.
    pub tokens: Option<JurorTokens>,
    /// Reliability weight applied during aggregation (ADR 0042).
    ///
    /// Defaults to `1.0`, which reproduces equal-weight voting. A higher
    /// weight makes this juror pull the verdict harder; a weight of `0.0`
    /// mutes the juror without dropping it from the report.
    pub weight: f64,
    /// Either a successful verdict tuple or the provider error that
    /// killed the call.
    pub result: Result<Vote, ProviderError>,
}

/// One successful juror's vote.
#[derive(Debug, Clone)]
pub struct Vote {
    /// The verdict the juror cast.
    pub verdict: Verdict,
    /// Confidence in `[0.0, 1.0]` if the juror returned one.
    pub score: Option<f64>,
    /// Free-text reasoning from the juror.
    pub reasoning: Option<String>,
}

/// Consensus method the runner asked the engine to apply.
///
/// Re-exported from [`crate::eval::cost`] so the engine, the cost-control
/// early-termination logic, and the jury reporter all name one type instead
/// of two that drifted apart.
pub use crate::eval::cost::ConsensusMethod;

/// Combine per-juror votes into a [`JuryConsensus`].
///
/// `votes` is the runner's per-juror result. Failed jurors are
/// preserved in the output as [`Juror`] entries with the matching
/// [`JurorStatus`], so reporters can render "rate-limited" or "timed
/// out" without re-parsing the error string.
///
/// `criteria` is the consensus method; the function returns whichever
/// verdict the method picks. When no juror returned a vote (every
/// juror failed) the function returns [`Verdict::Abstain`] so the
/// reporter has a clear signal that the jury did not decide.
pub fn evaluate_with_jury(votes: &[JurorVote], criteria: ConsensusMethod) -> JuryConsensus {
    let jurors: Vec<Juror> = votes.iter().map(juror_from_vote).collect();
    let successful_scores: Vec<f64> = jurors
        .iter()
        .filter(|j| j.status == JurorStatus::Ok)
        .filter_map(|j| j.score)
        .collect();
    // Pair each successful juror's verdict with the reliability weight the
    // caller set on its vote (ADR 0042). The report `Juror` does not carry
    // a weight, so we read it from the parallel `votes` slice; the two are
    // index-aligned because `jurors` is built from `votes` in order.
    let weighted: Vec<(Verdict, f64)> = jurors
        .iter()
        .zip(votes.iter())
        .filter(|(j, _)| j.status == JurorStatus::Ok)
        .map(|(j, v)| (j.verdict, v.weight))
        .collect();
    let verdict = decide(&weighted, criteria);
    let total_cost = total_cost_usd(&jurors);
    let score = if successful_scores.is_empty() {
        None
    } else {
        Some(successful_scores.iter().sum::<f64>() / successful_scores.len() as f64)
    };
    // Derive the confidence band and escalation flag from agreement
    //. Both ride on the same `compute_agreement` numbers the
    // reporter shows, so the verdict and the agreement block can never
    // disagree about how trustworthy the consensus is.
    let agreement = compute_agreement(&successful_scores);
    JuryConsensus {
        verdict,
        method: Some(criteria.label().to_owned()),
        score,
        total_cost_usd: total_cost,
        confidence: Some(agreement.confidence_band()),
        escalate_for_review: agreement.needs_escalation(),
        jurors,
    }
}

fn juror_from_vote(vote: &JurorVote) -> Juror {
    match &vote.result {
        Ok(v) => Juror {
            model: vote.model.clone(),
            provider: vote.provider.as_str().to_owned(),
            verdict: v.verdict,
            score: v.score,
            reasoning: v.reasoning.clone(),
            cost_usd: vote.cost_usd,
            tokens: vote.tokens.clone(),
            latency_ms: Some(vote.latency_ms),
            error: None,
            status: JurorStatus::Ok,
        },
        Err(err) => Juror {
            model: vote.model.clone(),
            provider: vote.provider.as_str().to_owned(),
            verdict: Verdict::Abstain,
            score: None,
            reasoning: None,
            cost_usd: vote.cost_usd,
            tokens: vote.tokens.clone(),
            latency_ms: Some(vote.latency_ms),
            error: Some(err.to_string()),
            status: provider_error_to_status(err),
        },
    }
}

/// Map a [`ProviderError`] onto a [`JurorStatus`] so failed jurors carry
/// the same vocabulary the reporter banners use (timeout, rate-limit,
/// parse-error, error). Auth and invalid-request collapse to
/// [`JurorStatus::Error`]: the operator response is "fix the config",
/// not "retry", which is the same response code path as any other
/// permanent failure.
pub fn provider_error_to_status(err: &ProviderError) -> JurorStatus {
    match err {
        ProviderError::Timeout(_) => JurorStatus::Timeout,
        ProviderError::RateLimit { .. } => JurorStatus::RateLimit,
        ProviderError::MalformedResponse(_) => JurorStatus::ParseError,
        ProviderError::AuthFailure(_)
        | ProviderError::InvalidRequest(_)
        | ProviderError::ProviderInternal(_)
        | ProviderError::Transport(_)
        | ProviderError::FeatureUnsupported { .. } => JurorStatus::Error,
    }
}

/// Fold the eligible jurors into one verdict using summed reliability
/// weights (ADR 0042).
///
/// `weighted` pairs each non-failed juror's verdict with its weight.
/// Abstentions are dropped before the fold. Majority compares the passing
/// weight against the failing weight; unanimous passes only when no
/// eligible juror failed. When every juror in the pool carries the default
/// weight of `1.0`, the summed-weight fold reproduces the head-count fold
/// exactly.
fn decide(weighted: &[(Verdict, f64)], method: ConsensusMethod) -> Verdict {
    let mut pass_weight = 0.0;
    let mut fail_weight = 0.0;
    let mut pass_count = 0u32;
    for (verdict, weight) in weighted {
        match verdict {
            Verdict::Pass => {
                pass_weight += weight;
                pass_count += 1;
            }
            Verdict::Fail => fail_weight += weight,
            Verdict::Abstain => {}
        }
    }
    if pass_weight == 0.0 && fail_weight == 0.0 {
        return Verdict::Abstain;
    }
    match method {
        ConsensusMethod::Majority => {
            if pass_weight > fail_weight {
                Verdict::Pass
            } else if fail_weight > pass_weight {
                Verdict::Fail
            } else {
                Verdict::Abstain
            }
        }
        ConsensusMethod::Unanimous => {
            if fail_weight == 0.0 && pass_weight > 0.0 {
                Verdict::Pass
            } else if pass_weight == 0.0 && fail_weight > 0.0 {
                Verdict::Fail
            } else {
                Verdict::Abstain
            }
        }
        // k-of-n counts passing jurors, not weight: the threshold is a
        // head count by definition, so the reliability weights do not
        // apply here.
        ConsensusMethod::KOfN { k } => {
            if pass_count >= k {
                Verdict::Pass
            } else {
                Verdict::Fail
            }
        }
    }
}

/// One juror configured for an engine-prompt dispatch run.
///
/// The provider is held as `&dyn LlmProvider` so the runner can pass a
/// mix of real and mocked providers without naming a concrete type. The
/// model identifier is provider-specific and lives next to the trait
/// object so callers do not have to thread it through a second slice.
pub struct JurorConfig<'a> {
    /// Stable identifier the reporter shows alongside the verdict.
    pub provider_id: ProviderId,
    /// Trait object the dispatcher calls.
    pub provider: &'a dyn LlmProvider,
    /// Model the provider should serve. Provider-specific.
    pub model: String,
    /// Reliability weight carried onto the juror's vote (ADR 0042).
    /// `1.0` is equal weight.
    pub weight: f64,
}

impl JurorConfig<'_> {
    /// The identity-only view of this juror that the shared dispatch loop
    /// needs; the loop never touches the provider itself.
    fn seat(&self) -> JurorSeat {
        JurorSeat {
            provider: self.provider_id,
            model: self.model.clone(),
            weight: self.weight,
        }
    }
}

/// Identity of one juror seat handed to [`dispatch_votes`].
///
/// Why a second config type next to [`JurorConfig`]: the shared dispatch
/// loop only needs the fields the report carries, so front-ends that own
/// their own vote-acquisition contract (the `llm-jury` matcher's judge
/// path) can build seats without holding a provider trait object.
#[derive(Debug, Clone)]
pub struct JurorSeat {
    /// Stable identifier the reporter shows alongside the verdict.
    pub provider: ProviderId,
    /// Model the juror runs on. Provider-specific.
    pub model: String,
    /// Reliability weight carried onto the juror's vote (ADR 0042).
    pub weight: f64,
}

/// Outcome of one juror call, produced by a [`dispatch_votes`] caller.
///
/// The shape is front-end neutral: the engine prompt path and the
/// matcher's judge path both reduce a juror to a vote-or-error plus the
/// telemetry the report carries.
pub struct JurorCall {
    /// The juror's vote, or the provider error that killed the call.
    pub result: Result<Vote, ProviderError>,
    /// Spend across every provider call this juror made, recorded into
    /// the dispatch budget. `None` records nothing: either no provider
    /// call completed, or the front-end cannot meter spend (the matcher's
    /// judge path does not expose usage).
    pub cost: Option<Cost>,
    /// Token counts behind `cost`, when the provider reported usage.
    pub tokens: Option<JurorTokens>,
    /// Wall-clock latency in milliseconds.
    pub latency_ms: u64,
}

/// Votes and cancellation count returned by [`dispatch_votes`].
#[derive(Debug)]
pub struct DispatchedVotes {
    /// One vote per seat, in seat order. Cancelled jurors carry an error
    /// result naming the cancellation reason.
    pub votes: Vec<JurorVote>,
    /// Seats that were never called because the run short-circuited on
    /// the cost budget or a decided consensus.
    pub cancelled_jurors: usize,
}

/// Fan one call per juror seat out through a caller-supplied dispatcher,
/// with per-juror error isolation, cost-budget enforcement, and optional
/// consensus short-circuiting.
///
/// This is the single dispatch loop behind both jury front-ends (the
/// WOR-1308 convergence): [`dispatch_jury`] supplies the engine's
/// verdict-JSON call, and the `llm-jury` matcher supplies its judge-based
/// call. `call` receives the seat index and returns that juror's outcome;
/// errors stay inside the returned [`JurorVote`] so one failed juror
/// cannot poison the rest.
///
/// Seats are dispatched sequentially because the budget check needs the
/// running spend before each call. Once the budget is exhausted, or once
/// `short_circuit` reports the verdict already pinned, the remaining
/// seats drain into cancelled votes without their call ever running.
/// Pass `short_circuit: None` when the caller's aggregation is not the
/// head-count [`ConsensusMethod`] (the matcher's weighted quorum), so a
/// cancelled juror cannot silently change the fold's denominators.
pub async fn dispatch_votes<F, Fut>(
    seats: &[JurorSeat],
    short_circuit: Option<ConsensusMethod>,
    budget: &mut CostBudget,
    mut call: F,
) -> DispatchedVotes
where
    F: FnMut(usize) -> Fut,
    Fut: Future<Output = JurorCall>,
{
    let total = seats.len() as u32;
    let mut votes: Vec<JurorVote> = Vec::with_capacity(seats.len());
    let mut cancelled_jurors = 0usize;
    let mut terminated = false;
    for (index, seat) in seats.iter().enumerate() {
        if terminated || budget_exceeded(budget) {
            cancelled_jurors += 1;
            votes.push(cancelled_vote(seat, terminated));
            continue;
        }
        let outcome = call(index).await;
        if let Some(cost) = &outcome.cost {
            budget.record_call(cost);
        }
        votes.push(JurorVote {
            provider: seat.provider,
            model: seat.model.clone(),
            latency_ms: outcome.latency_ms,
            cost_usd: outcome.cost.as_ref().map(Cost::total),
            tokens: outcome.tokens,
            weight: seat.weight,
            result: outcome.result,
        });
        // Consult the consensus-aware short-circuit: if the votes
        // collected so far already pin the verdict, skip the rest.
        if let Some(method) = short_circuit {
            if method
                .would_terminate_early(tally_votes(&votes, total))
                .is_some()
            {
                terminated = true;
            }
        }
    }
    DispatchedVotes {
        votes,
        cancelled_jurors,
    }
}

/// Outcome of [`dispatch_jury`].
///
/// `consensus` is the same shape [`evaluate_with_jury`] produces; the
/// extra fields surface signals the runner needs to attach to the report.
/// `cancelled_jurors` is the count the dispatcher stopped calling once the
/// run short-circuited (cost budget or decided consensus), so reporters can
/// render "2 jurors cancelled". `agreement` carries Krippendorff's alpha
/// plus variance from the completed jurors.
#[derive(Debug)]
pub struct JuryDispatchResult {
    /// Aggregate consensus, identical in shape to [`evaluate_with_jury`].
    pub consensus: JuryConsensus,
    /// Number of jurors the dispatcher skipped after the run
    /// short-circuited on the cost budget or a decided consensus.
    pub cancelled_jurors: usize,
    /// Inter-juror agreement metrics for the completed jurors.
    pub agreement: Agreement,
    /// True when [`BiasMitigations::position_swap`] was honoured for at
    /// least one juror. The dispatcher only applies the swap in
    /// [`DispatchMode::Pairwise`], where it re-grades the two candidates
    /// with their slot order flipped from `(A, B)` to `(B, A)`.
    pub position_swap_applied: bool,
}

/// Pairwise dispatch mode toggle.
///
/// `Single` is the default: each juror grades one response against the
/// rubric. `Pairwise` grades two distinct candidate responses against each
/// other. When [`BiasMitigations::position_swap`] is on, the dispatcher
/// evaluates each juror twice with the two candidates in opposite order,
/// `(A, B)` then `(B, A)`, and averages the two scores. Comparing the two
/// orderings is what surfaces position bias: a juror that prefers slot A
/// regardless of content scores `A` high in both passes.
///
/// The second candidate rides on the `Pairwise` variant rather than a
/// separate parameter so the type makes it impossible to ask for pairwise
/// grading without supplying both candidates. The `response` argument to
/// [`dispatch_jury`] is candidate A; `Pairwise(candidate_b)` is candidate B.
///
/// The pairwise machinery is fully dispatchable here; exposing it through
/// the YAML matcher contract (which today carries one candidate, not two)
/// is the follow-up noted on WOR-1296.
#[derive(Debug, Clone)]
pub enum DispatchMode {
    /// Single-response grading. One call per juror.
    Single,
    /// Pairwise grading of candidate A (the `response` argument) against
    /// the candidate B held here. Two calls per juror when position swap
    /// is on, with the candidate order swapped between the two calls.
    Pairwise {
        /// The second candidate response, graded against candidate A.
        candidate_b: String,
    },
}

/// Dispatch each configured juror through the engine's verdict-JSON
/// prompt contract, parse the responses, and aggregate.
///
/// This is the engine-prompt front-end over [`dispatch_votes`], which owns
/// the loop mechanics (budget enforcement, error isolation, cancellation
/// draining). What this wrapper adds is the prompt and reply contract from
/// the sibling `prompt` module, the pairwise [`DispatchMode`] with its
/// position swap, and the consensus short-circuit via
/// [`ConsensusMethod::would_terminate_early`]. The `llm-jury` matcher does
/// not call this function because its jurors speak the judge contract;
/// it shares [`dispatch_votes`] instead (see the module doc).
pub async fn dispatch_jury(
    jurors: &[JurorConfig<'_>],
    rubric: &str,
    response: &str,
    method: ConsensusMethod,
    budget: &mut CostBudget,
    mitigations: &BiasMitigations,
    mode: DispatchMode,
) -> JuryDispatchResult {
    let seats: Vec<JurorSeat> = jurors.iter().map(|c| c.seat()).collect();
    // The swap flag is engine-prompt knowledge (only the pairwise path can
    // apply it), so it rides on a Cell next to the dispatch closure rather
    // than widening the shared JurorCall vocabulary for every front-end.
    let swap_flag = Cell::new(false);
    let swap_ref = &swap_flag;
    let mode_ref = &mode;
    let dispatched = dispatch_votes(&seats, Some(method), budget, |index| {
        let cfg = &jurors[index];
        async move {
            let (call, applied_swap) =
                call_juror(cfg, rubric, response, mode_ref, mitigations).await;
            if applied_swap {
                swap_ref.set(true);
            }
            call
        }
    })
    .await;
    let consensus = evaluate_with_jury(&dispatched.votes, method);
    let successful_scores: Vec<f64> = consensus
        .jurors
        .iter()
        .filter(|j| j.status == JurorStatus::Ok)
        .filter_map(|j| j.score)
        .collect();
    let agreement = compute_agreement(&successful_scores);
    JuryDispatchResult {
        consensus,
        cancelled_jurors: dispatched.cancelled_jurors,
        agreement,
        position_swap_applied: swap_flag.get(),
    }
}

fn budget_exceeded(budget: &CostBudget) -> bool {
    match budget.cap() {
        Some(cap) => budget.spent() + 1e-9 >= cap,
        None => false,
    }
}

/// Build a [`ConsensusTally`] from the votes gathered so far.
///
/// Cancelled and failed jurors abstain rather than count toward pass or
/// fail, so a juror killed for cost cannot tip an early-termination check.
fn tally_votes(votes: &[JurorVote], total: u32) -> ConsensusTally {
    let mut passes = 0u32;
    let mut fails = 0u32;
    for v in votes {
        if let Ok(vote) = &v.result {
            match vote.verdict {
                Verdict::Pass => passes += 1,
                Verdict::Fail => fails += 1,
                Verdict::Abstain => {}
            }
        }
    }
    ConsensusTally {
        total,
        passes,
        fails,
    }
}

fn cancelled_vote(seat: &JurorSeat, consensus_reached: bool) -> JurorVote {
    let reason = if consensus_reached {
        "cancelled: consensus already decided"
    } else {
        "cancelled: cost budget exceeded"
    };
    JurorVote {
        provider: seat.provider,
        model: seat.model.clone(),
        latency_ms: 0,
        cost_usd: Some(0.0),
        tokens: None,
        weight: seat.weight,
        result: Err(ProviderError::InvalidRequest(reason.to_owned())),
    }
}

async fn call_juror(
    cfg: &JurorConfig<'_>,
    rubric: &str,
    response: &str,
    mode: &DispatchMode,
    mitigations: &BiasMitigations,
) -> (JurorCall, bool) {
    let options = juror_options(&cfg.model);
    // The first pass presents the candidates in their natural order. In
    // pairwise mode that is `(A, B)`; in single mode it is just the one
    // response. The swap pass below re-grades pairwise as `(B, A)`.
    let first_prompt = build_first_prompt(rubric, response, mode, mitigations.chain_of_thought);
    let start = Instant::now();
    let result = cfg.provider.chat_completion(&first_prompt, &options).await;
    let latency_ms = start.elapsed().as_millis() as u64;
    let (vote_outcome, cost, tokens, applied_swap) =
        finalize_call(cfg, mode, mitigations, rubric, response, result).await;
    (
        JurorCall {
            result: vote_outcome,
            cost,
            tokens,
            latency_ms,
        },
        applied_swap,
    )
}

async fn finalize_call(
    cfg: &JurorConfig<'_>,
    mode: &DispatchMode,
    mitigations: &BiasMitigations,
    rubric: &str,
    response: &str,
    first: Result<crate::eval::provider::Response, ProviderError>,
) -> (
    Result<Vote, ProviderError>,
    Option<Cost>,
    Option<JurorTokens>,
    bool,
) {
    let first = match first {
        Ok(r) => r,
        // No response, so no usage or spend to report.
        Err(e) => return (Err(e), None, None, false),
    };
    let mut cost_total = cfg.provider.estimate_cost(&first.usage, &cfg.model);
    // Token counts behind the cost, summed across the swap pass below so the
    // reported tokens and cost describe the same set of calls.
    let mut tokens = JurorTokens {
        input: first.usage.input_tokens,
        output: first.usage.output_tokens,
        cache_read: first.usage.cache_read_tokens,
        cache_write: first.usage.cache_write_tokens,
    };
    let first_vote = match parse_vote(&first.content) {
        Ok(v) => v,
        Err(e) => return (Err(e), Some(cost_total), Some(tokens), false),
    };
    let mut score = first_vote.score;
    let mut verdict = first_vote.verdict;
    let mut reasoning = first_vote.reasoning.clone();
    let mut swap_applied = false;
    // The swap pass only makes sense in pairwise mode: it re-grades the
    // same two candidates with their order flipped from `(A, B)` to
    // `(B, A)`. `candidate_b` is the second candidate carried on the mode.
    if let (DispatchMode::Pairwise { candidate_b }, true) = (mode, mitigations.position_swap) {
        let options = juror_options(&cfg.model);
        // Flip the order: pass 1 saw (response, candidate_b); pass 2 sees
        // (candidate_b, response). Grading both orderings is what lets the
        // averaged score cancel a juror's slot preference.
        let messages =
            build_pairwise_prompt(rubric, candidate_b, response, mitigations.chain_of_thought);
        match cfg.provider.chat_completion(&messages, &options).await {
            Ok(second) => {
                let second_cost = cfg.provider.estimate_cost(&second.usage, &cfg.model);
                cost_total = combine_costs(&cost_total, &second_cost);
                tokens.input += second.usage.input_tokens;
                tokens.output += second.usage.output_tokens;
                tokens.cache_read = combine_u32(tokens.cache_read, second.usage.cache_read_tokens);
                tokens.cache_write =
                    combine_u32(tokens.cache_write, second.usage.cache_write_tokens);
                if let Ok(second_vote) = parse_vote(&second.content) {
                    if let (Some(a), Some(b)) = (score, second_vote.score) {
                        score = Some((a + b) / 2.0);
                    }
                    if let Some(r) = second_vote.reasoning {
                        reasoning = match reasoning {
                            Some(mut existing) => {
                                existing.push_str(" | swap: ");
                                existing.push_str(&r);
                                Some(existing)
                            }
                            None => Some(r),
                        };
                    }
                    if verdict != second_vote.verdict {
                        verdict = Verdict::Abstain;
                    }
                    swap_applied = true;
                }
            }
            Err(_) => {
                // Treat a failed swap pass as advisory: keep the first verdict
                // rather than poisoning the juror's vote because of a flaky
                // second call.
            }
        }
    }
    let vote = Vote {
        verdict,
        score,
        reasoning,
    };
    (Ok(vote), Some(cost_total), Some(tokens), swap_applied)
}

fn combine_costs(a: &Cost, b: &Cost) -> Cost {
    Cost {
        input_dollars: a.input_dollars + b.input_dollars,
        output_dollars: a.output_dollars + b.output_dollars,
        cache_read_dollars: combine_option(a.cache_read_dollars, b.cache_read_dollars),
        cache_write_dollars: combine_option(a.cache_write_dollars, b.cache_write_dollars),
    }
}

fn combine_option(a: Option<f64>, b: Option<f64>) -> Option<f64> {
    match (a, b) {
        (Some(x), Some(y)) => Some(x + y),
        (Some(x), None) | (None, Some(x)) => Some(x),
        (None, None) => None,
    }
}

/// Sum two optional token counts, treating a present value as authoritative
/// when only one side reports it (mirrors `combine_option` for cache tokens).
fn combine_u32(a: Option<u32>, b: Option<u32>) -> Option<u32> {
    match (a, b) {
        (Some(x), Some(y)) => Some(x + y),
        (Some(x), None) | (None, Some(x)) => Some(x),
        (None, None) => None,
    }
}

/// Build the first-pass prompt for a juror.
///
/// In [`DispatchMode::Single`] the juror grades the one `response` against
/// the rubric. In [`DispatchMode::Pairwise`] the juror compares candidate A
/// (`response`) against candidate B in their natural `(A, B)` order; the
/// swap pass in `finalize_call` re-grades them as `(B, A)`.
fn build_first_prompt(
    rubric: &str,
    response: &str,
    mode: &DispatchMode,
    chain_of_thought: bool,
) -> Vec<Message> {
    match mode {
        DispatchMode::Single => build_prompt(rubric, response, chain_of_thought),
        DispatchMode::Pairwise { candidate_b } => {
            build_pairwise_prompt(rubric, response, candidate_b, chain_of_thought)
        }
    }
}

fn total_cost_usd(jurors: &[Juror]) -> Option<f64> {
    let mut found_any = false;
    let mut total = 0.0;
    for j in jurors {
        if let Some(c) = j.cost_usd {
            found_any = true;
            total += c;
        }
    }
    if found_any {
        Some(total)
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::eval::jury::agreement::ConfidenceBand;
    use std::time::Duration;

    fn passing_vote(model: &str) -> JurorVote {
        weighted_passing_vote(model, 1.0)
    }

    fn failing_vote(model: &str) -> JurorVote {
        weighted_failing_vote(model, 1.0)
    }

    fn weighted_passing_vote(model: &str, weight: f64) -> JurorVote {
        JurorVote {
            provider: ProviderId::Anthropic,
            model: model.into(),
            latency_ms: 100,
            cost_usd: Some(0.0001),
            tokens: None,
            weight,
            result: Ok(Vote {
                verdict: Verdict::Pass,
                score: Some(0.9),
                reasoning: Some("ok".into()),
            }),
        }
    }

    fn weighted_failing_vote(model: &str, weight: f64) -> JurorVote {
        JurorVote {
            provider: ProviderId::Openai,
            model: model.into(),
            latency_ms: 120,
            cost_usd: Some(0.0001),
            tokens: None,
            weight,
            result: Ok(Vote {
                verdict: Verdict::Fail,
                score: Some(0.3),
                reasoning: Some("bad".into()),
            }),
        }
    }

    /// Two-juror unanimous-pass consensus with known timings and costs.
    /// Once a `pub` fixture for the runner (pre-dispatch days); now test
    /// support only, kept for stable aggregation snapshots.
    fn deterministic_fixture() -> JuryConsensus {
        let votes = vec![
            JurorVote {
                provider: ProviderId::Anthropic,
                model: "claude-haiku-4".into(),
                latency_ms: 600,
                cost_usd: Some(0.00010),
                tokens: None,
                weight: 1.0,
                result: Ok(Vote {
                    verdict: Verdict::Pass,
                    score: Some(0.9),
                    reasoning: Some("matches rubric".into()),
                }),
            },
            JurorVote {
                provider: ProviderId::Openai,
                model: "gpt-4o-mini".into(),
                latency_ms: 800,
                cost_usd: Some(0.00011),
                tokens: None,
                weight: 1.0,
                result: Ok(Vote {
                    verdict: Verdict::Pass,
                    score: Some(0.8),
                    reasoning: Some("matches rubric".into()),
                }),
            },
        ];
        evaluate_with_jury(&votes, ConsensusMethod::Unanimous)
    }

    #[test]
    fn unanimous_passes_when_every_juror_passes() {
        let votes = vec![passing_vote("a"), passing_vote("b")];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Unanimous);
        assert_eq!(c.verdict, Verdict::Pass);
        assert_eq!(c.method.as_deref(), Some("unanimous"));
    }

    #[test]
    fn unanimous_abstains_when_jurors_split() {
        let votes = vec![passing_vote("a"), failing_vote("b")];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Unanimous);
        assert_eq!(c.verdict, Verdict::Abstain);
    }

    #[test]
    fn majority_picks_pass_when_two_of_three_pass() {
        let votes = vec![passing_vote("a"), passing_vote("b"), failing_vote("c")];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Majority);
        assert_eq!(c.verdict, Verdict::Pass);
    }

    #[test]
    fn default_weight_reproduces_head_count_majority() {
        // Two pass, one fail, all default weight: the weighted fold must
        // match the head-count fold from before ADR 0042.
        let votes = vec![passing_vote("a"), passing_vote("b"), failing_vote("c")];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Majority);
        assert_eq!(c.verdict, Verdict::Pass);
    }

    #[test]
    fn high_weight_dissenter_flips_majority_to_fail() {
        // One failing juror with weight 3.0 outweighs two passing jurors at
        // the default weight of 1.0 each, so the majority verdict flips.
        let votes = vec![
            weighted_passing_vote("a", 1.0),
            weighted_passing_vote("b", 1.0),
            weighted_failing_vote("c", 3.0),
        ];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Majority);
        assert_eq!(c.verdict, Verdict::Fail);
    }

    #[test]
    fn high_weight_juror_pulls_majority_to_pass() {
        // The mirror case: one heavy passing juror outvotes two light
        // failing jurors.
        let votes = vec![
            weighted_passing_vote("a", 5.0),
            weighted_failing_vote("b", 1.0),
            weighted_failing_vote("c", 1.0),
        ];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Majority);
        assert_eq!(c.verdict, Verdict::Pass);
    }

    #[test]
    fn zero_weight_juror_does_not_decide_a_tie() {
        // A muted (weight 0.0) failing juror cannot tip a single passing
        // juror; pass weight still exceeds zero fail weight.
        let votes = vec![
            weighted_passing_vote("a", 1.0),
            weighted_failing_vote("b", 0.0),
        ];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Majority);
        assert_eq!(c.verdict, Verdict::Pass);
    }

    fn scored_pass(model: &str, score: f64) -> JurorVote {
        JurorVote {
            provider: ProviderId::Anthropic,
            model: model.into(),
            latency_ms: 100,
            cost_usd: Some(0.0001),
            tokens: None,
            weight: 1.0,
            result: Ok(Vote {
                verdict: Verdict::Pass,
                score: Some(score),
                reasoning: Some("ok".into()),
            }),
        }
    }

    #[test]
    fn unanimous_high_agreement_jury_reports_high_confidence_without_escalation() {
        // Three jurors, near-identical scores: high agreement, so the
        // verdict carries a High band and does not ask for review.
        let votes = vec![
            scored_pass("a", 0.90),
            scored_pass("b", 0.88),
            scored_pass("c", 0.91),
        ];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Unanimous);
        assert_eq!(c.confidence, Some(ConfidenceBand::High));
        assert!(!c.escalate_for_review);
    }

    #[test]
    fn split_jury_reports_low_confidence_and_flags_for_review() {
        // A polarized split keeps the majority verdict but flags it: the
        // engine does not average the disagreement away silently.
        let votes = vec![
            scored_pass("a", 0.95),
            scored_pass("b", 0.05),
            scored_pass("c", 0.90),
        ];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Majority);
        assert_eq!(c.verdict, Verdict::Pass);
        assert_eq!(c.confidence, Some(ConfidenceBand::Low));
        assert!(c.escalate_for_review);
    }

    #[test]
    fn pre_wor722_serialized_consensus_still_parses() {
        // A report serialized before  has neither field. Defaults
        // must fill in: no confidence band, no escalation.
        let json = r#"{
            "verdict": "pass",
            "method": "majority",
            "score": 0.8,
            "jurors": []
        }"#;
        let c: JuryConsensus = serde_json::from_str(json).expect("legacy shape parses");
        assert_eq!(c.verdict, Verdict::Pass);
        assert_eq!(c.confidence, None);
        assert!(!c.escalate_for_review);
    }

    #[test]
    fn total_cost_rolls_up_from_jurors() {
        let votes = vec![passing_vote("a"), passing_vote("b")];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Majority);
        let total = c.total_cost_usd.expect("cost present");
        assert!((total - 0.0002).abs() < 1e-9);
    }

    #[test]
    fn failed_juror_carries_timeout_status() {
        let votes = vec![
            passing_vote("a"),
            JurorVote {
                provider: ProviderId::Openai,
                model: "gpt".into(),
                latency_ms: 15_000,
                cost_usd: None,
                tokens: None,
                weight: 1.0,
                result: Err(ProviderError::Timeout(Duration::from_secs(15))),
            },
        ];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Majority);
        assert_eq!(c.jurors[1].status, JurorStatus::Timeout);
        assert!(c.jurors[1].error.is_some());
    }

    #[test]
    fn failed_juror_carries_rate_limit_status() {
        let votes = vec![
            passing_vote("a"),
            JurorVote {
                provider: ProviderId::Openai,
                model: "gpt".into(),
                latency_ms: 50,
                cost_usd: None,
                tokens: None,
                weight: 1.0,
                result: Err(ProviderError::RateLimit { retry_after: None }),
            },
        ];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Majority);
        assert_eq!(c.jurors[1].status, JurorStatus::RateLimit);
    }

    #[test]
    fn failed_juror_carries_parse_error_status() {
        let votes = vec![
            passing_vote("a"),
            JurorVote {
                provider: ProviderId::Openai,
                model: "gpt".into(),
                latency_ms: 50,
                cost_usd: None,
                tokens: None,
                weight: 1.0,
                result: Err(ProviderError::MalformedResponse("not json".into())),
            },
        ];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Majority);
        assert_eq!(c.jurors[1].status, JurorStatus::ParseError);
    }

    #[test]
    fn all_failed_jurors_collapses_to_abstain() {
        let votes = vec![
            JurorVote {
                provider: ProviderId::Openai,
                model: "gpt".into(),
                latency_ms: 50,
                cost_usd: None,
                tokens: None,
                weight: 1.0,
                result: Err(ProviderError::Timeout(Duration::from_secs(15))),
            },
            JurorVote {
                provider: ProviderId::Anthropic,
                model: "claude".into(),
                latency_ms: 50,
                cost_usd: None,
                tokens: None,
                weight: 1.0,
                result: Err(ProviderError::RateLimit { retry_after: None }),
            },
        ];
        let c = evaluate_with_jury(&votes, ConsensusMethod::Majority);
        assert_eq!(c.verdict, Verdict::Abstain);
    }

    #[test]
    fn deterministic_fixture_returns_unanimous_pass() {
        let c = deterministic_fixture();
        assert_eq!(c.verdict, Verdict::Pass);
        assert_eq!(c.jurors.len(), 2);
        assert!(c.total_cost_usd.unwrap() > 0.0);
    }

    use crate::eval::provider::{
        Capabilities, CompletionOptions, FinishReason, Response, ResponseContent, Role, Usage,
    };
    use async_trait::async_trait;
    use std::sync::Mutex;

    /// Provider that returns a fixed verdict and records every prompt it
    /// was given, so a test can prove no other juror's output reached it.
    struct RecordingProvider {
        verdict: &'static str,
        seen_prompts: Mutex<Vec<String>>,
    }

    impl RecordingProvider {
        fn new(verdict: &'static str) -> Self {
            Self {
                verdict,
                seen_prompts: Mutex::new(Vec::new()),
            }
        }
    }

    #[async_trait]
    impl LlmProvider for RecordingProvider {
        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 joined = messages
                .iter()
                .map(|m| m.content.clone())
                .collect::<Vec<_>>()
                .join("\n");
            if let Ok(mut seen) = self.seen_prompts.lock() {
                seen.push(joined);
            }
            Ok(Response {
                content: ResponseContent::Text {
                    text: format!(r#"{{"verdict": "{}", "score": 0.9}}"#, self.verdict),
                },
                finish_reason: FinishReason::Stop,
                usage: Usage::default(),
                model: "stub".into(),
            })
        }

        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 jurors_are_scored_independently_without_cross_visibility() {
        // ADR 0042: jurors must score blind. Dispatch two jurors that cast
        // opposite verdicts and assert neither juror's prompt mentions the
        // other juror's verdict or reasoning. The engine collects votes and
        // only folds them after every call, so no juror can see another.
        let pass_juror = RecordingProvider::new("pass");
        let fail_juror = RecordingProvider::new("fail");
        let jurors = vec![
            JurorConfig {
                provider_id: ProviderId::Anthropic,
                provider: &pass_juror,
                model: "a".into(),
                weight: 1.0,
            },
            JurorConfig {
                provider_id: ProviderId::Openai,
                provider: &fail_juror,
                model: "b".into(),
                weight: 1.0,
            },
        ];
        let mut budget = CostBudget::unbounded();
        let mitigations = BiasMitigations::default();
        let _ = dispatch_jury(
            &jurors,
            "rubric text",
            "response under test",
            ConsensusMethod::Majority,
            &mut budget,
            &mitigations,
            DispatchMode::Single,
        )
        .await;

        let pass_prompts = pass_juror.seen_prompts.lock().expect("lock");
        let fail_prompts = fail_juror.seen_prompts.lock().expect("lock");
        assert_eq!(pass_prompts.len(), 1);
        assert_eq!(fail_prompts.len(), 1);
        // The pass juror's prompt must not carry the fail juror's verdict,
        // and vice versa. Both prompts carry only the shared rubric and the
        // single response under test.
        for prompt in pass_prompts.iter().chain(fail_prompts.iter()) {
            assert!(prompt.contains("rubric text"));
            assert!(prompt.contains("response under test"));
            assert!(
                !prompt.contains("\"verdict\": \"fail\""),
                "no juror should see another juror's verdict: {prompt}"
            );
            assert!(
                !prompt.contains("\"verdict\": \"pass\""),
                "no juror should see another juror's verdict: {prompt}"
            );
        }
    }

    /// Provider that records the user-message text of every call. Used to
    /// prove the two pairwise passes present the candidates in opposite
    /// slot order.
    struct OrderRecordingProvider {
        seen_users: Mutex<Vec<String>>,
    }

    impl OrderRecordingProvider {
        fn new() -> Self {
            Self {
                seen_users: Mutex::new(Vec::new()),
            }
        }
    }

    #[async_trait]
    impl LlmProvider for OrderRecordingProvider {
        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 == Role::User)
                .map(|m| m.content.clone())
                .unwrap_or_default();
            if let Ok(mut seen) = self.seen_users.lock() {
                seen.push(user);
            }
            Ok(Response {
                content: ResponseContent::Text {
                    text: r#"{"verdict": "pass", "score": 0.5}"#.into(),
                },
                finish_reason: FinishReason::Stop,
                usage: Usage::default(),
                model: "stub".into(),
            })
        }

        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 position_swap_grades_the_two_candidates_in_opposite_orders() {
        // The position-bias mitigation only works if the two passes really
        // see the candidates in swapped slots. Pass 1 must present
        // (A, B); pass 2 must present (B, A). A no-op swap (re-grading the
        // same ordering) would leave both passes with A before B.
        let provider = OrderRecordingProvider::new();
        let jurors = vec![JurorConfig {
            provider_id: ProviderId::Anthropic,
            provider: &provider,
            model: "a".into(),
            weight: 1.0,
        }];
        let mut budget = CostBudget::unbounded();
        let mitigations = BiasMitigations {
            position_swap: true,
            ..BiasMitigations::default()
        };
        let _ = dispatch_jury(
            &jurors,
            "rubric",
            "CANDIDATE_A_MARKER",
            ConsensusMethod::Majority,
            &mut budget,
            &mitigations,
            DispatchMode::Pairwise {
                candidate_b: "CANDIDATE_B_MARKER".into(),
            },
        )
        .await;

        let users = provider.seen_users.lock().expect("lock");
        assert_eq!(users.len(), 2, "pairwise + swap dispatches two calls");

        // Pass 1: candidate A appears before candidate B.
        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}");

        // Pass 2: the order is flipped, so candidate B now precedes A.
        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}");
    }

    /// Provider that models a position-biased juror: it scores whichever
    /// candidate sits in the first slot of the prompt highly, regardless of
    /// content. The `high`/`low` scores ride on the slot, not the candidate
    /// under test, which is exactly the bias position swap corrects.
    struct PositionBiasedProvider {
        first_marker: &'static str,
        second_marker: &'static str,
    }

    #[async_trait]
    impl LlmProvider for PositionBiasedProvider {
        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 == Role::User)
                .map(|m| m.content.clone())
                .unwrap_or_default();
            // The score field describes candidate A. A position-biased
            // juror scores candidate A high only when it sits in the first
            // slot, low when it has been bumped to the second slot.
            let pos_first = user.find(self.first_marker);
            let pos_second = user.find(self.second_marker);
            let score = match (pos_first, pos_second) {
                (Some(a), Some(b)) if a < b => 0.9,
                _ => 0.3,
            };
            Ok(Response {
                content: ResponseContent::Text {
                    text: format!(r#"{{"verdict": "pass", "score": {score}}}"#),
                },
                finish_reason: FinishReason::Stop,
                usage: Usage::default(),
                model: "stub".into(),
            })
        }

        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 position_biased_juror_is_corrected_by_averaging_the_two_orderings() {
        // A juror that always favours the first-slot candidate would score
        // candidate A at 0.9 in a single un-swapped pass. With the swap the
        // second pass scores candidate A at 0.3 (now in slot two), and the
        // average lands at 0.6, cancelling the slot preference. A no-op
        // swap would leave the inflated 0.9.
        let provider = PositionBiasedProvider {
            first_marker: "CANDIDATE_A_MARKER",
            second_marker: "CANDIDATE_B_MARKER",
        };
        let jurors = vec![JurorConfig {
            provider_id: ProviderId::Anthropic,
            provider: &provider,
            model: "a".into(),
            weight: 1.0,
        }];
        let mut budget = CostBudget::unbounded();
        let mitigations = BiasMitigations {
            position_swap: true,
            ..BiasMitigations::default()
        };
        let result = dispatch_jury(
            &jurors,
            "rubric",
            "CANDIDATE_A_MARKER",
            ConsensusMethod::Majority,
            &mut budget,
            &mitigations,
            DispatchMode::Pairwise {
                candidate_b: "CANDIDATE_B_MARKER".into(),
            },
        )
        .await;

        assert!(result.position_swap_applied);
        let score = result.consensus.score.expect("score present");
        assert!(
            (score - 0.6).abs() < 1e-9,
            "averaging 0.9 and 0.3 should yield 0.6, got {score}"
        );
    }
}
