//! Model-facing reporter (the `agent` format).
//!
//! Every other reporter targets a human terminal or a CI system. This one
//! targets a model reading the result inside an agent loop, where every token
//! costs money and the model wants the verdict, the failures, and a next
//! action, nothing else. The shape:
//!
//! 1. One `VERDICT` line: pass or fail, the pass count over the total, and the
//!    failed / inconclusive / cached / duration breakdown.
//! 2. One block per failed test (failures only by default), each carrying the
//!    assertion, the actual value truncated to a budget, and a one-line
//!    `repro` command the agent can run to reproduce the single failure.
//! 3. An `OMITTED` tail when the token budget clips the failure list, so the
//!    agent knows results were dropped and how to see them.
//!
//! Field names are stable and line-oriented (`VERDICT`, `FAIL`, `assert:`,
//! `actual:`, `repro:`, `OMITTED`) so a model parses the result without
//! fetching a schema. The budget is approximate tokens (a chars-over-four
//! heuristic, the same order of magnitude as a BPE tokenizer) so the whole
//! result fits a target size regardless of how large any single actual value
//! is.

use std::fmt::Write as _;
use std::io::Write;

use serde::Serialize;

use crate::report::{Report, Reporter, TestResult, TestStatus};

/// Default token budget for the agent reporter. Roughly a thousand tokens
/// leaves the failures legible while staying a small fraction of a typical
/// tool-result allowance. Override with [`AgentReporter::with_token_budget`].
pub const DEFAULT_TOKEN_BUDGET: usize = 1024;

/// How many characters of an actual value to keep before the per-field
/// truncation marker. Picked so a typical assertion diff survives intact while
/// a multi-kilobyte blob does not blow the budget on its own.
const ACTUAL_FIELD_CHARS: usize = 240;

/// Reporter that renders a [`Report`] in the compact, model-facing `agent`
/// format (see module docs).
///
/// Construct with [`AgentReporter::default`] for the default budget, or
/// [`AgentReporter::with_token_budget`] to fit a specific result size.
pub struct AgentReporter {
    /// Approximate token ceiling for the whole rendered result.
    token_budget: usize,
    /// Suite path used in `repro:` lines for tests that carry no `file`,
    /// so the command stays executable instead of missing its argument.
    suite_path: Option<String>,
}

impl Default for AgentReporter {
    fn default() -> Self {
        Self {
            token_budget: DEFAULT_TOKEN_BUDGET,
            suite_path: None,
        }
    }
}

impl AgentReporter {
    /// Set the approximate token ceiling for the rendered result. Failure
    /// blocks past the budget are dropped and summarized in the `OMITTED`
    /// tail. The `VERDICT` line is always emitted, even when the budget is
    /// smaller than it, so the agent always learns the verdict.
    pub fn with_token_budget(mut self, tokens: usize) -> Self {
        self.token_budget = tokens;
        self
    }

    /// Set the suite path `repro:` lines fall back to when a test carries
    /// no `file`. The live runner passes the config path it ran (or the
    /// persisted inline suite), so every repro line is executable verbatim.
    pub fn with_suite_path(mut self, path: impl Into<String>) -> Self {
        self.suite_path = Some(path.into());
        self
    }
}

/// Estimate the token count of a string with a chars-over-four heuristic.
///
/// This is deliberately tokenizer-agnostic: it is within a small constant
/// factor of a real BPE count for English-plus-JSON text, which is all the
/// budget accounting needs. Exposed so tests can assert the agent reporter is
/// materially smaller than the JSON envelope on a fixed fixture.
pub fn estimate_tokens(s: &str) -> usize {
    s.chars().count().div_ceil(4)
}

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

    fn render(&self, report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
        out.write_all(self.render_to_string(report).as_bytes())
    }
}

impl AgentReporter {
    fn render_to_string(&self, report: &Report) -> String {
        let mut out = String::new();
        self.render_verdict(&mut out, report);
        let failures: Vec<&TestResult> = report
            .tests
            .iter()
            .filter(|t| matches!(t.status, TestStatus::Fail))
            .collect();
        self.render_failures(&mut out, &failures, &report.run_id);
        out
    }

    /// One-line verdict and counts, always emitted first.
    fn render_verdict(&self, out: &mut String, report: &Report) {
        let s = &report.summary;
        let verdict = if s.failed > 0 { "fail" } else { "pass" };
        let _ = writeln!(
            out,
            "VERDICT {verdict} {passed}/{total} passed ({failed} failed, {inconclusive} inconclusive, {cached} cached, {duration}ms)",
            passed = s.passed,
            total = s.total,
            failed = s.failed,
            inconclusive = s.inconclusive,
            cached = s.cache_hits,
            duration = s.duration_ms,
        );
    }

    /// Failure blocks, dropped and summarized once the budget is spent.
    ///
    /// The verdict line is already on the wire, so the budget here governs the
    /// failure list only. We add blocks until the next one would cross the
    /// budget (reserving room for the omission tail), then emit `OMITTED`.
    fn render_failures(&self, out: &mut String, failures: &[&TestResult], run_id: &str) {
        // Reserve a fixed slice of the budget for the omission tail so a
        // budget spent exactly at a block boundary still has room to say so.
        const TAIL_RESERVE_TOKENS: usize = 24;
        let spent_on_verdict = estimate_tokens(out);
        let cap = self
            .token_budget
            .saturating_sub(spent_on_verdict)
            .saturating_sub(TAIL_RESERVE_TOKENS);
        let mut shown = 0usize;
        for test in failures {
            let block = render_one_failure(test, run_id, self.suite_path.as_deref());
            // Always show at least the first failure so a tiny budget still
            // yields one actionable result rather than only a verdict.
            if shown > 0 && estimate_tokens(&block) > cap.saturating_sub(estimate_tokens(out)) {
                break;
            }
            out.push_str(&block);
            shown += 1;
        }
        let remaining = failures.len().saturating_sub(shown);
        if remaining > 0 {
            let _ = writeln!(
                out,
                "OMITTED {remaining} more failures (raise the agent reporter token budget to see them)"
            );
        }
    }
}

/// A single failure, pre-digested for a model: the test name, the failed
/// assertion, the actual value (truncated to a field budget), and a one-line
/// repro command that re-runs just this case.
///
/// This is the structured shape the agent reporter renders and the
/// `mcptest mcp-server` `run_tool_test` verb returns, so the two surfaces
/// cannot drift. Build one with [`AgentFailure::from_test`].
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct AgentFailure {
    /// The failing test's name.
    pub test: String,
    /// The failed assertion, collapsed to one line and truncated to a
    /// field budget.
    pub assert: String,
    /// The actual value the server returned, truncated to a field budget.
    /// Absent when the failure carried no structured diff.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub actual: Option<String>,
    /// Resource URI carrying the full persisted output when `assert` or
    /// `actual` was clipped. Absent when nothing was dropped or when the
    /// run carried no id (so nothing was persisted to point at).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub full: Option<String>,
    /// A one-line command that re-runs just this failing test.
    pub repro: String,
}

impl AgentFailure {
    /// Pre-digest one failing [`TestResult`] into the model-facing shape.
    /// Returns `None` for a test that did not fail. Without a run id or a
    /// suite path; prefer [`AgentFailure::from_test_in_run`] when the
    /// caller knows the run, so clipped values carry their escape hatch.
    pub fn from_test(test: &TestResult) -> Option<Self> {
        Self::from_test_in_run(test, "", None)
    }

    /// Pre-digest one failing [`TestResult`], pointing clipped fields at
    /// the run's persisted full output and falling back to `suite_path`
    /// in the repro when the test carries no `file`.
    pub fn from_test_in_run(
        test: &TestResult,
        run_id: &str,
        suite_path: Option<&str>,
    ) -> Option<Self> {
        if !matches!(test.status, TestStatus::Fail) {
            return None;
        }
        let assert_line = test
            .failure
            .as_ref()
            .map(|f| one_line(&f.message))
            .unwrap_or_default();
        let actual_full = actual_value(test);
        let clipped = exceeds(&assert_line, ACTUAL_FIELD_CHARS)
            || actual_full
                .as_deref()
                .is_some_and(|a| exceeds(a, ACTUAL_FIELD_CHARS));
        let full = (clipped && !run_id.is_empty())
            .then(|| crate::report::run_store::test_output_uri(run_id, &test.name));
        Some(Self {
            test: test.name.clone(),
            assert: truncate(&assert_line, ACTUAL_FIELD_CHARS),
            actual: actual_full.map(|a| truncate(&a, ACTUAL_FIELD_CHARS)),
            full,
            repro: repro_command(test, suite_path),
        })
    }
}

/// Every failure in `report`, pre-digested into the model-facing shape.
///
/// Shared by the agent reporter and the `run_tool_test` verb so the
/// structured failure an agent reads is identical whichever surface it came
/// through. Clipped fields point at the run's persisted full output via
/// `report.run_id`; pass `suite_path` so file-less tests still get an
/// executable repro.
pub fn agent_failures_with(report: &Report, suite_path: Option<&str>) -> Vec<AgentFailure> {
    report
        .tests
        .iter()
        .filter_map(|t| AgentFailure::from_test_in_run(t, &report.run_id, suite_path))
        .collect()
}

/// [`agent_failures_with`] without a suite-path fallback. Kept as the
/// stable entry point existing callers compiled against.
pub fn agent_failures(report: &Report) -> Vec<AgentFailure> {
    agent_failures_with(report, None)
}

/// Render one failure block: the test name, the assertion, the truncated
/// actual value, the full-output URI when something was clipped, and a
/// one-line repro command. Free function so the budget loop can size a
/// block before committing it.
fn render_one_failure(test: &TestResult, run_id: &str, suite_path: Option<&str>) -> String {
    let Some(failure) = AgentFailure::from_test_in_run(test, run_id, suite_path) else {
        return String::new();
    };
    let mut block = String::new();
    let _ = writeln!(block, "FAIL {}", failure.test);
    if !failure.assert.is_empty() {
        let _ = writeln!(block, "  assert: {}", failure.assert);
    }
    if let Some(actual) = &failure.actual {
        let _ = writeln!(block, "  actual: {actual}");
    }
    if let Some(full) = &failure.full {
        let _ = writeln!(block, "  full: {full}");
    }
    let _ = writeln!(block, "  repro: {}", failure.repro);
    block
}

/// The actual value an agent should look at, pulled from the failure diff.
///
/// The diff is the structured "expected vs actual" text; we surface its
/// `actual:` line when present, else the whole diff, so the agent sees what
/// the server returned rather than re-deriving it from the message.
fn actual_value(test: &TestResult) -> Option<String> {
    let diff = test.failure.as_ref()?.diff.as_ref()?;
    let actual_line = diff
        .lines()
        .find_map(|l| l.trim_start().strip_prefix("actual:"));
    match actual_line {
        Some(rest) => Some(rest.trim().to_string()),
        None => Some(one_line(diff)),
    }
}

/// A one-line repro command for a single failing test. Filtering by the exact
/// name re-runs just this case, which is the next action an agent takes.
/// Tests without a `file` fall back to the caller-provided suite path so
/// the command still names the suite it should run.
fn repro_command(test: &TestResult, suite_path: Option<&str>) -> String {
    match test.file.as_deref().or(suite_path) {
        Some(file) => format!("mcptest run {file} --filter {:?}", test.name),
        None => format!("mcptest run --filter {:?}", test.name),
    }
}

/// True when `s` would be clipped by [`truncate`] at `max` characters.
fn exceeds(s: &str, max: usize) -> bool {
    s.chars().count() > max
}

/// Collapse a multi-line string to its first non-empty line so a field stays
/// on one line. Agent output is line-oriented; a stray newline would split a
/// field across two parse units.
fn one_line(s: &str) -> String {
    s.lines()
        .map(str::trim)
        .find(|l| !l.is_empty())
        .unwrap_or("")
        .to_string()
}

/// Truncate to at most `max` characters, appending a marker with the dropped
/// count so the agent knows the value was clipped (and by how much).
fn truncate(s: &str, max: usize) -> String {
    let count = s.chars().count();
    if count <= max {
        return s.to_string();
    }
    let kept: String = s.chars().take(max).collect();
    let dropped = count - max;
    format!("{kept} ...(+{dropped} chars)")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::report::reporters::JsonReporter;
    use crate::report::{TestFailure, TestResult};

    fn render(report: &Report, budget: usize) -> String {
        let mut buf = Vec::new();
        AgentReporter::default()
            .with_token_budget(budget)
            .render(report, &mut buf)
            .unwrap();
        String::from_utf8(buf).unwrap()
    }

    #[test]
    fn agent_reporter_name_is_stable() {
        assert_eq!(AgentReporter::default().name(), "agent");
    }

    #[test]
    fn verdict_line_leads_with_pass_fail_and_counts() {
        let s = render(&Report::sample(), DEFAULT_TOKEN_BUDGET);
        let first = s.lines().next().unwrap();
        assert_eq!(
            first,
            "VERDICT fail 2/3 passed (1 failed, 0 inconclusive, 1 cached, 250ms)"
        );
    }

    #[test]
    fn all_pass_report_reads_pass_and_lists_no_failures() {
        let s = render(&Report::sample_all_pass(), DEFAULT_TOKEN_BUDGET);
        assert!(s.starts_with("VERDICT pass 1/1 passed"));
        assert!(!s.contains("FAIL "));
    }

    #[test]
    fn failure_block_carries_assert_actual_and_repro() {
        let s = render(&Report::sample(), DEFAULT_TOKEN_BUDGET);
        assert!(s.contains("FAIL create_issue rejects empty title"));
        assert!(s.contains("  assert: expected error but tool returned ok"));
        assert!(s.contains("  actual: ok with id=42"));
        assert!(s.contains("  repro: mcptest run tests/create_issue.yml --filter"));
    }

    #[test]
    fn actual_value_is_truncated_to_the_field_budget() {
        let mut r = Report::sample_all_pass();
        let big = "x".repeat(2000);
        r.summary.failed = 1;
        r.summary.passed = 0;
        r.tests[0].status = TestStatus::Fail;
        r.tests[0].failure = Some(TestFailure {
            message: "value too large".into(),
            diff: Some(format!("  expected: small\n  actual: {big}")),
            network_diagnostic: None,
        });
        let s = render(&r, DEFAULT_TOKEN_BUDGET);
        assert!(s.contains("...(+"));
        assert!(s.contains("chars)"));
        // The whole 2000-char blob must not survive into the output.
        assert!(!s.contains(&big));
    }

    #[test]
    fn budget_clips_failures_and_emits_an_omitted_tail() {
        let mut r = Report::sample();
        r.tests.clear();
        for i in 0..50 {
            r.tests.push(TestResult {
                name: format!("failing case {i}"),
                status: TestStatus::Fail,
                duration_ms: 5,
                cache_hit: false,
                started_at: None,
                ended_at: None,
                failure: Some(TestFailure {
                    message: format!("assertion {i} failed for a reason"),
                    diff: Some(format!("  expected: ok\n  actual: error number {i}")),
                    network_diagnostic: None,
                }),
                file: Some(format!("tests/case_{i}.yml")),
                line: Some(1),
                steps: Vec::new(),
                consensus: None,
                rule_id: None,
                severity: None,
                score: None,
                derived_metrics: Vec::new(),
                natural_language: false,
            });
        }
        r.summary.total = 50;
        r.summary.failed = 50;
        r.summary.passed = 0;
        r.summary.cache_hits = 0;
        let s = render(&r, 200);
        assert!(s.contains("OMITTED"));
        assert!(s.contains("more failures"));
        // The verdict and at least one failure still made it out.
        assert!(s.starts_with("VERDICT fail"));
        assert!(s.contains("FAIL failing case 0"));
        // And the whole thing stayed near the budget.
        assert!(
            estimate_tokens(&s) <= 200,
            "rendered {} tokens",
            estimate_tokens(&s)
        );
    }

    #[test]
    fn tiny_budget_still_emits_verdict_and_first_failure() {
        let s = render(&Report::sample(), 1);
        assert!(s.starts_with("VERDICT fail"));
        assert!(s.contains("FAIL create_issue rejects empty title"));
    }

    #[test]
    fn agent_output_is_materially_smaller_than_json() {
        let r = Report::sample_full();
        let agent = render(&r, DEFAULT_TOKEN_BUDGET);
        let mut json_buf = Vec::new();
        JsonReporter::default().render(&r, &mut json_buf).unwrap();
        let json = String::from_utf8(json_buf).unwrap();
        let agent_tokens = estimate_tokens(&agent);
        let json_tokens = estimate_tokens(&json);
        assert!(
            agent_tokens * 2 < json_tokens,
            "agent {agent_tokens} tokens vs json {json_tokens} tokens, expected agent under half"
        );
    }

    #[test]
    fn repro_command_quotes_the_test_name() {
        let s = render(&Report::sample(), DEFAULT_TOKEN_BUDGET);
        assert!(s.contains("--filter \"create_issue rejects empty title\""));
    }

    #[test]
    fn agent_failures_extracts_only_failed_tests_with_repro() {
        let failures = agent_failures(&Report::sample());
        assert_eq!(failures.len(), 1);
        let f = &failures[0];
        assert_eq!(f.test, "create_issue rejects empty title");
        assert_eq!(f.assert, "expected error but tool returned ok");
        assert_eq!(f.actual.as_deref(), Some("ok with id=42"));
        assert!(f
            .repro
            .contains("--filter \"create_issue rejects empty title\""));
    }

    #[test]
    fn agent_failures_serializes_with_stable_field_names() {
        let failures = agent_failures(&Report::sample());
        let json = serde_json::to_value(&failures[0]).unwrap();
        assert!(json.get("test").is_some());
        assert!(json.get("assert").is_some());
        assert!(json.get("actual").is_some());
        assert!(json.get("repro").is_some());
        // No truncation in the sample, so the escape-hatch field is absent
        // and the serialized shape is unchanged for existing consumers.
        assert!(json.get("full").is_none());
    }

    fn report_with_large_actual() -> Report {
        let mut r = Report::sample_all_pass();
        let big = "x".repeat(2000);
        r.summary.failed = 1;
        r.summary.passed = 0;
        r.tests[0].status = TestStatus::Fail;
        r.tests[0].failure = Some(TestFailure {
            message: "value too large".into(),
            diff: Some(format!("  expected: small\n  actual: {big}")),
            network_diagnostic: None,
        });
        r
    }

    #[test]
    fn truncated_actual_emits_the_full_output_resource_uri() {
        let r = report_with_large_actual();
        let s = render(&r, DEFAULT_TOKEN_BUDGET);
        assert!(s.contains("...(+"));
        let expected_uri = format!(
            "  full: mcptest://runs/{}/tests/smoke_test/output",
            r.run_id
        );
        assert!(s.contains(&expected_uri), "missing full line in:\n{s}");
    }

    #[test]
    fn untruncated_failure_emits_no_full_line() {
        let s = render(&Report::sample(), DEFAULT_TOKEN_BUDGET);
        assert!(!s.contains("  full: "));
    }

    #[test]
    fn empty_run_id_suppresses_the_full_line_even_when_clipped() {
        let mut r = report_with_large_actual();
        r.run_id = String::new();
        let s = render(&r, DEFAULT_TOKEN_BUDGET);
        assert!(s.contains("...(+"));
        assert!(!s.contains("  full: "));
    }

    #[test]
    fn oversized_assert_message_is_clipped_and_gets_the_uri() {
        let mut r = Report::sample_all_pass();
        r.summary.failed = 1;
        r.summary.passed = 0;
        r.tests[0].status = TestStatus::Fail;
        r.tests[0].failure = Some(TestFailure {
            message: format!("at `root`, got {}", "y".repeat(2000)),
            diff: None,
            network_diagnostic: None,
        });
        let failures = agent_failures_with(&r, None);
        assert!(failures[0].assert.contains("...(+"));
        assert_eq!(
            failures[0].full.as_deref(),
            Some(format!("mcptest://runs/{}/tests/smoke_test/output", r.run_id).as_str())
        );
    }

    #[test]
    fn fileless_test_repro_falls_back_to_the_suite_path() {
        let mut r = Report::sample();
        r.tests[2].file = None;
        let failures = agent_failures_with(&r, Some(".mcptest/inline/run-1.yml"));
        assert_eq!(
            failures[0].repro,
            "mcptest run .mcptest/inline/run-1.yml --filter \"create_issue rejects empty title\""
        );
        // The rendered reporter takes the same fallback via the builder.
        let mut buf = Vec::new();
        AgentReporter::default()
            .with_suite_path(".mcptest/inline/run-1.yml")
            .render(&r, &mut buf)
            .unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("repro: mcptest run .mcptest/inline/run-1.yml --filter"));
    }

    #[test]
    fn test_file_still_wins_over_the_suite_path_fallback() {
        let r = Report::sample();
        let failures = agent_failures_with(&r, Some("other.yml"));
        assert!(failures[0].repro.contains("tests/create_issue.yml"));
    }
}
