//! Plain-text pretty reporter.

use std::io::Write;
use std::sync::Arc;

use crate::coverage::CoverageReport;
use crate::redaction::Redactor;
use crate::report::{Juror, JuryConsensus, Report, Reporter, TestResult, TestStatus};
use crate::style::Styler;

/// Max chars of juror reasoning surfaced in the pretty reporter. Full
/// reasoning still lives in the JSON output for postmortem inspection.
const MAX_PRETTY_REASONING: usize = 500;

/// Plain-text pretty reporter. Emits ANSI color for the status marks and
/// summary when [`PrettyReporter::with_color`] is set (the CLI turns it on per
/// the resolved `--color` choice); plain text otherwise.
///
/// The reporter accepts an optional [`Redactor`] via
/// [`PrettyReporter::with_redactor`]. When set, every user-supplied
/// string (test name, failure message, diff body) is run through the
/// redactor before being written. The simpler integration is to call
/// [`Report::redacted`] once at the call site and hand the redacted
/// report to any reporter; this builder is the per-reporter escape
/// hatch.
///
/// Per-step durations render under each test line when
/// [`PrettyReporter::with_verbose`] is set. Per-juror verdicts
/// render under each consensus line when
/// [`PrettyReporter::with_juror_details`] is set; without it the
/// reporter prints only the consensus verdict.
#[derive(Default)]
pub struct PrettyReporter {
    redactor: Option<Arc<Redactor>>,
    verbose: bool,
    show_juror_details: bool,
    coverage: Option<CoverageReport>,
    color: bool,
}

impl PrettyReporter {
    /// Attach a redactor so user-supplied strings render through it.
    pub fn with_redactor(mut self, redactor: Arc<Redactor>) -> Self {
        self.redactor = Some(redactor);
        self
    }

    /// Render per-step durations under each test line.
    pub fn with_verbose(mut self, verbose: bool) -> Self {
        self.verbose = verbose;
        self
    }

    /// Expand the per-juror block under each consensus line.
    pub fn with_juror_details(mut self, show: bool) -> Self {
        self.show_juror_details = show;
        self
    }

    /// Colorize the status marks and summary with ANSI codes. Off by default
    /// (plain text); the CLI turns it on per the resolved `--color` choice.
    pub fn with_color(mut self, color: bool) -> Self {
        self.color = color;
        self
    }

    /// The styler for this render, enabled per [`Self::with_color`].
    fn styler(&self) -> Styler {
        Styler::new(self.color)
    }

    /// Attach a coverage rollup so the end-of-run block prints the
    /// per-dimension percentages.
    pub fn with_coverage(mut self, coverage: CoverageReport) -> Self {
        self.coverage = Some(coverage);
        self
    }

    fn redact<'a>(&self, s: &'a str) -> std::borrow::Cow<'a, str> {
        match &self.redactor {
            Some(r) => r.redact_value(s),
            None => std::borrow::Cow::Borrowed(s),
        }
    }
}

impl Reporter for PrettyReporter {
    fn name(&self) -> &'static str {
        "pretty"
    }

    fn render(&self, report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
        writeln!(
            out,
            "mcptest {} run {}",
            report.mcptest_version, report.run_id
        )?;
        writeln!(out)?;
        for test in &report.tests {
            self.write_test_line(out, test)?;
            self.write_failure(out, test)?;
            self.write_steps(out, test)?;
            self.write_consensus(out, test.consensus.as_ref(), "    ")?;
        }
        writeln!(out)?;
        // Slowest-5 block above the summary so operators see hot tests
        // before they scroll for the pass/fail counts.
        let slowest = report.slowest(5);
        if slowest.len() > 1 {
            writeln!(out, "Slowest tests:")?;
            for (i, test) in slowest.iter().enumerate() {
                let name = self.redact(&test.name);
                writeln!(out, "  {}. {}  ({} ms)", i + 1, name, test.duration_ms)?;
            }
            writeln!(out)?;
        }
        let sty = self.styler();
        let passed = sty.pass(&format!("{} passed", report.summary.passed));
        // Color the failure count red only when there is a failure to draw the
        // eye; a clean run keeps it plain.
        let failed = if report.summary.failed > 0 {
            sty.fail(&format!("{} failed", report.summary.failed))
        } else {
            format!("{} failed", report.summary.failed)
        };
        let skipped = sty.dim(&format!("{} skipped", report.summary.skipped));
        // Surface inconclusive verdicts only when present, so a run with none
        // keeps the legacy "passed, failed, skipped" summary unchanged.
        let inconclusive = if report.summary.inconclusive > 0 {
            format!(
                ", {}",
                sty.dim(&format!("{} inconclusive", report.summary.inconclusive))
            )
        } else {
            String::new()
        };
        writeln!(
            out,
            "Summary: {passed}, {failed}, {skipped}{inconclusive} in {} ms",
            report.summary.duration_ms
        )?;
        // Cold-vs-cached delta is only useful when at least one test
        // actually hit the cache. We emit total time on the cold path
        // for the comparison so the reader can eyeball the savings.
        if report.summary.cache_hits > 0 {
            writeln!(
                out,
                "Cache: {} hit, {} ms cached vs {} ms cold",
                report.summary.cache_hits,
                report.cached_duration_ms(),
                report.cold_duration_ms()
            )?;
        }
        // Cost telemetry footer. Surfaced only when the run
        // recorded at least one juror call; otherwise the line would
        // print zeros for every plain assertion run.
        if let Some(cost) = report.cost.as_ref() {
            writeln!(
                out,
                "Cost: ${:.4} total, ${:.5}/call avg, ${:.5}/test avg ({} model calls across {} tests)",
                cost.total_cost_usd,
                cost.cost_per_juror_avg,
                cost.cost_per_test_avg,
                cost.juror_calls,
                cost.tests_with_cost
            )?;
        }
        // Coverage footer. One line, three percentages so a
        // CI reader can eyeball the gate without scrolling.
        if let Some(c) = self.coverage.as_ref() {
            writeln!(out, "Coverage: {}", c.pretty_one_liner())?;
        }
        self.write_protocol_violations(report, out)?;
        Ok(())
    }
}

impl PrettyReporter {
    /// Trailer naming each server's protocol violations (WOR-1311). Why a
    /// trailer rather than per-test detail: the evidence is recorded per
    /// connection, so it belongs next to the run summary, and a clean run
    /// prints nothing so the legacy output is unchanged.
    fn write_protocol_violations(
        &self,
        report: &Report,
        out: &mut dyn Write,
    ) -> std::io::Result<()> {
        if report.protocol_violations.is_empty() {
            return Ok(());
        }
        writeln!(out, "Protocol violations:")?;
        for v in &report.protocol_violations {
            let example = self.redact(&v.example);
            if example.is_empty() {
                writeln!(out, "  {}: {} x{}", v.server, v.kind, v.count)?;
            } else {
                writeln!(
                    out,
                    "  {}: {} x{} (e.g. {example})",
                    v.server, v.kind, v.count
                )?;
            }
        }
        Ok(())
    }
}

impl PrettyReporter {
    fn write_test_line(&self, out: &mut dyn Write, test: &TestResult) -> std::io::Result<()> {
        let sty = self.styler();
        let mark = match test.status {
            TestStatus::Pass => sty.pass("PASS"),
            TestStatus::Fail => sty.fail("FAIL"),
            TestStatus::Skip => sty.dim("SKIP"),
            TestStatus::Inconclusive => sty.dim("INCONC"),
        };
        let name = self.redact(&test.name);
        let cached = if test.cache_hit { " cached" } else { "" };
        let step_suffix = if test.steps.is_empty() {
            String::new()
        } else {
            format!(", {} steps", test.steps.len())
        };
        writeln!(
            out,
            "  [{}] {}  ({} ms{}{})",
            mark, name, test.duration_ms, cached, step_suffix
        )
    }

    fn write_failure(&self, out: &mut dyn Write, test: &TestResult) -> std::io::Result<()> {
        let Some(failure) = &test.failure else {
            return Ok(());
        };
        let msg = self.redact(&failure.message);
        writeln!(out, "      {msg}")?;
        if let Some(diff) = &failure.diff {
            let diff = self.redact(diff);
            for line in diff.lines() {
                writeln!(out, "      {line}")?;
            }
        }
        Ok(())
    }

    fn write_steps(&self, out: &mut dyn Write, test: &TestResult) -> std::io::Result<()> {
        if !self.verbose || test.steps.is_empty() {
            return Ok(());
        }
        for (i, step) in test.steps.iter().enumerate() {
            let label = self.redact(&step.name);
            let cached = if step.cache_hit { " cached" } else { "" };
            writeln!(
                out,
                "      step {}: {}  ({} ms{})",
                i + 1,
                label,
                step.duration_ms,
                cached
            )?;
            self.write_consensus(out, step.consensus.as_ref(), "        ")?;
        }
        Ok(())
    }

    fn write_consensus(
        &self,
        out: &mut dyn Write,
        consensus: Option<&JuryConsensus>,
        indent: &str,
    ) -> std::io::Result<()> {
        let Some(c) = consensus else {
            return Ok(());
        };
        let verdict = verdict_word(c.verdict);
        let method = c
            .method
            .as_deref()
            .map(|m| format!(", {m}"))
            .unwrap_or_default();
        let score = c
            .score
            .map(|s| format!(", score={s:.2}"))
            .unwrap_or_default();
        let cost = c
            .total_cost_usd
            .map(|c| format!(", ${c:.5}"))
            .unwrap_or_default();
        writeln!(
            out,
            "{indent}jury: {verdict} ({} jurors{method}{score}{cost})",
            c.jurors.len()
        )?;
        if let Some(band) = c.confidence {
            writeln!(out, "{indent}  confidence: {}", confidence_word(band))?;
        }
        if c.escalate_for_review {
            writeln!(
                out,
                "{indent}  escalate for review (low inter-juror agreement)"
            )?;
        }
        if self.show_juror_details {
            for juror in &c.jurors {
                self.write_juror(out, juror, indent)?;
            }
        }
        Ok(())
    }

    fn write_juror(&self, out: &mut dyn Write, juror: &Juror, indent: &str) -> std::io::Result<()> {
        let status = juror_status_word(juror.status);
        let verdict = verdict_word(juror.verdict);
        let score = juror
            .score
            .map(|s| format!(" score={s:.2}"))
            .unwrap_or_default();
        let cost = juror
            .cost_usd
            .map(|c| format!(" ${c:.5}"))
            .unwrap_or_default();
        let latency = juror
            .latency_ms
            .map(|l| format!(" {l}ms"))
            .unwrap_or_default();
        writeln!(
            out,
            "{indent}  - {}/{}: {verdict} [{status}]{score}{cost}{latency}",
            juror.provider, juror.model
        )?;
        if let Some(reasoning) = &juror.reasoning {
            let r = self.redact(reasoning);
            let truncated = truncate_for_pretty(&r);
            writeln!(out, "{indent}    reasoning: {truncated}")?;
        }
        if let Some(err) = &juror.error {
            let r = self.redact(err);
            writeln!(out, "{indent}    error: {r}")?;
        }
        Ok(())
    }
}

fn verdict_word(v: crate::report::Verdict) -> &'static str {
    use crate::report::Verdict;
    match v {
        Verdict::Pass => "pass",
        Verdict::Fail => "fail",
        Verdict::Abstain => "abstain",
    }
}

/// Lowercase the confidence band for the pretty consensus line. A plain word
/// reads cleaner under the `jury:` line than the enum's PascalCase name and
/// matches the lowercase verdict and status words already rendered there.
fn confidence_word(b: crate::eval::jury::agreement::ConfidenceBand) -> &'static str {
    use crate::eval::jury::agreement::ConfidenceBand;
    match b {
        ConfidenceBand::High => "high",
        ConfidenceBand::Medium => "medium",
        ConfidenceBand::Low => "low",
    }
}

fn juror_status_word(s: crate::report::JurorStatus) -> &'static str {
    use crate::report::JurorStatus;
    match s {
        JurorStatus::Ok => "ok",
        JurorStatus::Timeout => "timeout",
        JurorStatus::RateLimit => "rate-limit",
        JurorStatus::ParseError => "parse-error",
        JurorStatus::Error => "error",
    }
}

fn truncate_for_pretty(s: &str) -> String {
    if s.chars().count() <= MAX_PRETTY_REASONING {
        return s.to_owned();
    }
    let mut out: String = s.chars().take(MAX_PRETTY_REASONING).collect();
    out.push_str("...");
    out
}

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

    #[test]
    fn with_color_emits_ansi_marks_and_default_stays_plain() {
        let r = Report::sample();
        let mut colored = Vec::new();
        PrettyReporter::default()
            .with_color(true)
            .render(&r, &mut colored)
            .unwrap();
        let colored = String::from_utf8(colored).unwrap();
        assert!(
            colored.contains('\u{1b}'),
            "expected ANSI codes with color on"
        );

        let mut plain = Vec::new();
        PrettyReporter::default().render(&r, &mut plain).unwrap();
        let plain = String::from_utf8(plain).unwrap();
        assert!(!plain.contains('\u{1b}'), "default reporter must be plain");
        assert!(plain.contains("[PASS]"));
    }

    #[test]
    fn pretty_reporter_renders_summary_and_each_test() {
        let r = Report::sample();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("[PASS] search returns at least one hit"));
        assert!(s.contains("[FAIL] create_issue rejects empty title"));
        assert!(s.contains("Summary: 2 passed, 1 failed, 0 skipped"));
    }

    #[test]
    fn pretty_reporter_renders_an_inconclusive_verdict_and_summary() {
        let mut r = Report::sample();
        r.tests[0].status = crate::report::TestStatus::Inconclusive;
        r.summary.passed = 1;
        r.summary.inconclusive = 1;
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("[INCONC]"), "{s}");
        assert!(s.contains("1 inconclusive"), "{s}");
    }

    #[test]
    fn pretty_reporter_includes_failure_diff() {
        let r = Report::sample();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("expected error but tool returned ok"));
        assert!(s.contains("expected: error code"));
    }

    #[test]
    fn pretty_reporter_name_is_stable() {
        assert_eq!(PrettyReporter::default().name(), "pretty");
    }

    #[test]
    fn pretty_reporter_shows_slowest_block_above_summary() {
        let r = Report::sample();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        let slowest_pos = s.find("Slowest tests:").expect("slowest block");
        let summary_pos = s.find("Summary:").expect("summary line");
        assert!(slowest_pos < summary_pos, "slowest should precede summary");
        // The 169 ms failing test is the slowest in the sample.
        assert!(s.contains("create_issue rejects empty title"));
    }

    #[test]
    fn pretty_reporter_shows_cold_vs_cached_delta() {
        let r = Report::sample();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("Cache: 1 hit"));
        assert!(s.contains("cached vs"));
    }

    #[test]
    fn pretty_reporter_emits_cost_footer_when_present() {
        use crate::report::RunCost;
        let mut r = Report::sample();
        r.cost = Some(RunCost {
            total_cost_usd: 0.4200,
            cost_per_juror_avg: 0.05250,
            cost_per_test_avg: 0.21000,
            juror_calls: 8,
            tests_with_cost: 2,
            cost_per_completed_task: Some(0.21000),
        });
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("Cost: $0.4200 total"));
        assert!(s.contains("8 model calls"));
        assert!(s.contains("across 2 tests"));
    }

    #[test]
    fn pretty_reporter_omits_cost_footer_when_absent() {
        let r = Report::sample();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("Cost:"));
    }

    #[test]
    fn pretty_reporter_marks_cache_hits_inline() {
        let r = Report::sample();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        // The list_issues test is the cached one in the sample.
        assert!(s.contains("list_issues respects pagination"));
        assert!(s.contains("cached"));
    }

    #[test]
    fn pretty_reporter_redacts_token_shaped_test_name() {
        let mut r = Report::sample();
        r.tests[0].name = "ghp_abcdefghijklmnop1234".into();
        let reporter = PrettyReporter::default().with_redactor(Arc::new(Redactor::new()));
        let mut buf = Vec::new();
        reporter.render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("ghp_abcdefghijklmnop1234"));
        assert!(s.contains("[REDACTED:token]"));
    }

    // ----- Per-step timing --------------------------------------

    #[test]
    fn pretty_reporter_marks_step_count_inline_when_steps_present() {
        let r = Report::sample_stepwise();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("3 steps"));
    }

    #[test]
    fn pretty_reporter_hides_per_step_durations_without_verbose() {
        let r = Report::sample_stepwise();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("step 1:"));
    }

    #[test]
    fn pretty_reporter_lists_each_step_under_verbose() {
        let r = Report::sample_stepwise();
        let mut buf = Vec::new();
        PrettyReporter::default()
            .with_verbose(true)
            .render(&r, &mut buf)
            .unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("step 1: create_issue"));
        assert!(s.contains("step 2: list_issues"));
        assert!(s.contains("step 3: list_issues (cached)"));
        // The cached step prints "cached" inline.
        assert!(s.matches("cached").count() >= 1);
    }

    // ----- Per-juror verdict ------------------------------------

    fn report_with_consensus() -> Report {
        let mut r = Report::sample();
        r.tests[0].consensus = Some(JuryConsensus::sample());
        r
    }

    #[test]
    fn pretty_reporter_renders_collapsed_consensus_by_default() {
        let r = report_with_consensus();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("jury: pass"));
        assert!(s.contains("majority"));
        // Collapsed mode hides per-juror lines.
        assert!(!s.contains("openai/gpt-4o-mini"));
    }

    #[test]
    fn pretty_reporter_renders_confidence_band_on_consensus() {
        // The sample consensus carries ConfidenceBand::High, so the collapsed
        // line should surface it as a plain lowercase word.
        let r = report_with_consensus();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("confidence: high"), "{s}");
    }

    #[test]
    fn pretty_reporter_flags_escalation_when_set() {
        let mut r = Report::sample();
        let mut c = JuryConsensus::sample();
        c.escalate_for_review = true;
        r.tests[0].consensus = Some(c);
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("escalate"), "{s}");
    }

    #[test]
    fn pretty_reporter_expands_jurors_under_show_juror_details() {
        let r = report_with_consensus();
        let mut buf = Vec::new();
        PrettyReporter::default()
            .with_juror_details(true)
            .render(&r, &mut buf)
            .unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("openai/gpt-4o-mini"));
        assert!(s.contains("anthropic/claude-haiku-4"));
        assert!(s.contains("google/gemini-1.5-flash"));
        assert!(s.contains("reasoning:"));
        assert!(s.contains("Response cites at least one supporting fact"));
    }

    #[test]
    fn pretty_reporter_expands_runner_attached_consensus() {
        // Sibling test running against runner-produced data
        // rather than the JuryConsensus::sample() fixture.
        use crate::eval::jury::engine::{ConsensusMethod, JurorVote, Vote};
        use crate::eval::provider::ProviderId;
        use crate::report::Verdict;
        use crate::runner::attach_to_test;

        let mut r = Report::sample();
        let votes = vec![
            JurorVote {
                provider: ProviderId::Anthropic,
                model: "claude-haiku-4".into(),
                latency_ms: 600,
                cost_usd: Some(0.00012),
                tokens: None,
                weight: 1.0,
                result: Ok(Vote {
                    verdict: Verdict::Pass,
                    score: Some(0.95),
                    reasoning: Some("ok".into()),
                }),
            },
            JurorVote {
                provider: ProviderId::Openai,
                model: "gpt-4o-mini".into(),
                latency_ms: 700,
                cost_usd: Some(0.00010),
                tokens: None,
                weight: 1.0,
                result: Ok(Vote {
                    verdict: Verdict::Pass,
                    score: Some(0.85),
                    reasoning: Some("ok".into()),
                }),
            },
        ];
        attach_to_test(&mut r.tests[0], &votes, ConsensusMethod::Unanimous);
        let mut buf = Vec::new();
        PrettyReporter::default()
            .with_juror_details(true)
            .render(&r, &mut buf)
            .unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("jury: pass"));
        assert!(s.contains("anthropic/claude-haiku-4"));
        assert!(s.contains("openai/gpt-4o-mini"));
    }

    #[test]
    fn pretty_reporter_renders_juror_failure_status() {
        let r = report_with_consensus();
        let mut buf = Vec::new();
        PrettyReporter::default()
            .with_juror_details(true)
            .render(&r, &mut buf)
            .unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("[timeout]"));
        assert!(s.contains("error: request timed out after 15s"));
    }

    #[test]
    fn pretty_reporter_renders_coverage_summary_when_attached() {
        use crate::coverage::CoverageReport;
        let r = Report::sample();
        let mut buf = Vec::new();
        PrettyReporter::default()
            .with_coverage(CoverageReport::sample())
            .render(&r, &mut buf)
            .unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("Coverage:"));
        assert!(s.contains("50% tool coverage"));
    }

    #[test]
    fn pretty_reporter_omits_coverage_footer_when_unset() {
        let r = Report::sample();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("Coverage:"));
    }

    #[test]
    fn pretty_reporter_renders_protocol_violations_trailer() {
        use crate::report::ProtocolViolationReport;
        let mut r = Report::sample();
        r.protocol_violations = vec![ProtocolViolationReport {
            server: "github".into(),
            kind: "string-id echo".into(),
            count: 3,
            example: "server echoed numeric request id 1 as the string \"1\"".into(),
        }];
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("Protocol violations:"), "{s}");
        assert!(s.contains("github: string-id echo x3"), "{s}");
        assert!(s.contains("e.g. server echoed numeric request id 1"), "{s}");
    }

    #[test]
    fn pretty_reporter_omits_protocol_violations_trailer_when_clean() {
        let r = Report::sample();
        let mut buf = Vec::new();
        PrettyReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("Protocol violations:"));
    }

    #[test]
    fn pretty_reporter_truncates_long_reasoning() {
        let mut r = Report::sample();
        let mut c = JuryConsensus::sample();
        c.jurors[0].reasoning = Some("a".repeat(600));
        r.tests[0].consensus = Some(c);
        let mut buf = Vec::new();
        PrettyReporter::default()
            .with_juror_details(true)
            .render(&r, &mut buf)
            .unwrap();
        let s = String::from_utf8(buf).unwrap();
        // The full 600-char run must not appear; the trailing ellipsis must.
        assert!(!s.contains(&"a".repeat(600)));
        assert!(s.contains("..."));
    }
}
