//! Report folding and exit-code gating for `mcptest run`.
//!
//! The tail of the live run: fold the post-drive capability-lane verdicts onto
//! the report, emit and persist it, then reconcile every gate (tool-selection
//! floors, the matrix floor, the timing baseline, coverage, and the gating
//! lanes) into the single process exit code. Split out of `run_live` so that
//! module stays under the production-module-size guard; behavior is unchanged.

use anyhow::Result;

use crate::cli::handlers::run_live_options::fold_exit_code;
use crate::cli::handlers::run_live_plans::RecordedCell;
use crate::cli::handlers::run_select::write_last_run_state;
use crate::cli::Reporter;

/// Fold the post-run capability lanes onto the report, emit and persist it, gate
/// on the tool-selection floor, matrix floor, baseline, and gating lanes, and
/// return the process exit code. Split out of `run_live_async` for the
/// function-length lint.
#[allow(clippy::too_many_arguments)]
pub(super) fn report_and_gate(
    report: &mut mcptest_core::report::Report,
    recorded_cells: &[RecordedCell],
    floor_outcomes: &[(String, mcptest_core::eval::SelectionReport, bool)],
    matrix_cells: &[super::run_live_matrix::MatrixCell],
    suite: &mcptest_core::suite::ResolvedSuite,
    journal: Option<&mut mcptest_core::runner::resume::RunJournal>,
    journal_path: &std::path::Path,
    config_path: &std::path::Path,
    quiet: bool,
    output: Option<&std::path::Path>,
    reporter: Option<Reporter>,
    annotations: crate::cli::global::AnnotationsMode,
    color: crate::cli::global::ColorWhen,
    check_baseline: Option<&std::path::Path>,
    tolerance_pct: f64,
    agent_budget: Option<usize>,
    pinned_run_id: Option<&str>,
    inconclusive_fails_run: bool,
    coverage: Option<mcptest_core::coverage::CoverageReport>,
    coverage_threshold: Option<&mcptest_core::coverage::Thresholds>,
    redactor: &mcptest_core::redaction::Redactor,
    lane_verdicts: Vec<super::run_live_lanes::LaneVerdict>,
    failure_policy: mcptest_core::eval::FailurePolicy,
) -> Result<i32> {
    use mcptest_core::report::TestStatus;

    // Fold the lane verdicts onto the report; a gating failure flips the exit
    // code, advisory failures render but stay out of the tally below (WOR-1491).
    let lane_fold = super::run_live_lanes::fold_lane_verdicts(report, lane_verdicts, quiet);

    // Identity first: the reporters and the persisted artifacts must agree
    // on the run id (the agent reporter prints full-output URIs keyed by it).
    super::run_live_print::finalize_run_identity(report, pinned_run_id);

    super::run_live_print::emit_report(
        report,
        output,
        reporter,
        annotations,
        color,
        quiet,
        agent_budget,
        Some(config_path),
        coverage.as_ref(),
        redactor,
    )?;

    // Persist the full per-test outputs and the report, keyed by run id, so
    // truncated failures stay recoverable without a re-run.
    super::run_live_print::persist_run_artifacts(report, redactor);

    // Persist the failure list so `--last-failed` and `--failed-first` can
    // target it on the next invocation.
    let failed_names: Vec<String> = report
        .tests
        .iter()
        .filter(|t| matches!(t.status, TestStatus::Fail))
        .map(|t| t.name.clone())
        .collect();
    write_last_run_state(config_path, &failed_names);

    if let Some(journal) = journal {
        write_resume_journal(journal, report, recorded_cells, journal_path);
    }

    let floor_failed = gate_tool_selection_floors(floor_outcomes, quiet);
    let matrix_failed =
        super::run_live_matrix::gate_matrix_asserts(report, matrix_cells, suite, quiet);
    let baseline_failed = super::run_live_baseline::gate_timing_baseline(
        report,
        check_baseline,
        tolerance_pct,
        quiet,
    )?;
    // WOR-1386: a `--coverage-threshold` breach is a distinct quality-gate
    // failure (exit 6, matching `mcptest coverage`), surfaced only when the run
    // otherwise passed so a louder test failure (exit 1) is not masked.
    let coverage_failed = match &coverage {
        Some(report) => super::run_live_coverage::gate_threshold(report, coverage_threshold, quiet),
        None => false,
    };

    // WOR-1508: the opt-in failure policy downgrades classified transient /
    // external failures out of the exit code, so a flaky upstream does not turn
    // the build red. Off by default; only confident classifications downgrade.
    let downgraded = downgrade_flaky_failures(report, failure_policy, quiet);
    let effective_failed = report
        .summary
        .failed
        .saturating_sub(lane_fold.advisory_failed)
        .saturating_sub(downgraded);
    let base = fold_exit_code(
        effective_failed,
        floor_failed || matrix_failed || baseline_failed || lane_fold.gating_failed,
        report.summary.inconclusive,
        inconclusive_fails_run,
    );
    Ok(if base == 0 && coverage_failed {
        crate::cli::exit_codes::EXIT_COVERAGE_FLOOR
    } else {
        base
    })
}

/// Count and warn on failed tests the opt-in failure policy downgrades out of
/// the exit code (WOR-1508). Each failure is classified by its message; only a
/// transient/external source the policy opts into, classified above the
/// confidence floor, downgrades. Returns the count to subtract from the gating
/// tally. A no-op (returns 0) when the policy is off.
fn downgrade_flaky_failures(
    report: &mcptest_core::report::Report,
    policy: mcptest_core::eval::FailurePolicy,
    quiet: bool,
) -> usize {
    use mcptest_core::eval::{apply_policy, classify_failure, Disposition};
    use mcptest_core::report::TestStatus;
    if policy.is_off() {
        return 0;
    }
    let mut downgraded = 0;
    for test in report
        .tests
        .iter()
        .filter(|t| matches!(t.status, TestStatus::Fail))
    {
        let Some(failure) = test.failure.as_ref() else {
            continue;
        };
        let classification = classify_failure(&failure.message, None, None);
        if apply_policy(&classification, policy) == Disposition::Downgrade {
            downgraded += 1;
            if !quiet {
                eprintln!(
                    "failure-policy: downgraded `{}` out of the exit code ({:?}, {:.0}% confidence): {}",
                    test.name,
                    classification.source,
                    classification.confidence * 100.0,
                    classification.remediation
                );
            }
        }
    }
    downgraded
}

/// Gate on the tool-selection floors. Returns `true` when any
/// configured floor was not met, mirroring how `--coverage-threshold` gates.
/// Reuses exit code 1 (test failures) rather than minting a new code: a floor
/// breach is a failing test outcome.
fn gate_tool_selection_floors(
    floor_outcomes: &[(String, mcptest_core::eval::SelectionReport, bool)],
    quiet: bool,
) -> bool {
    let floor_failed = floor_outcomes.iter().any(|(_, _, met)| !met);
    if floor_failed && !quiet {
        for (case, rep, met) in floor_outcomes {
            if !*met {
                eprintln!(
                    "  FLOOR {}: tool-selection floor not met ({})",
                    case,
                    rep.cost_vs_accuracy()
                );
            }
        }
    }
    floor_failed
}

///  record-mode resume journal write-back. For every cell dispatched
/// live this run, mark it complete only when all of its reporter rows passed,
/// then save incrementally so an abort after this point still keeps progress.
/// A cell with any failing row is left out so a resumed run re-runs it.
fn write_resume_journal(
    journal: &mut mcptest_core::runner::resume::RunJournal,
    report: &mcptest_core::report::Report,
    recorded_cells: &[RecordedCell],
    journal_path: &std::path::Path,
) {
    use mcptest_core::report::TestStatus;
    let passed_rows: std::collections::BTreeSet<&str> = report
        .tests
        .iter()
        .filter(|t| matches!(t.status, TestStatus::Pass))
        .map(|t| t.name.as_str())
        .collect();
    for cell in recorded_cells {
        let all_passed = !cell.rows.is_empty()
            && cell
                .rows
                .iter()
                .all(|row| passed_rows.contains(row.as_str()));
        if all_passed {
            journal.mark_complete(&cell.agent, &cell.model_id);
            if let Err(e) = journal.save(journal_path) {
                // A journal write failure is not fatal: the run still produced
                // its report and cassettes. Warn so the operator knows a
                // resume may redo this cell.
                eprintln!(
                    "mcptest run: could not save resume journal to {}: {e}",
                    journal_path.display()
                );
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::downgrade_flaky_failures;
    use mcptest_core::eval::FailurePolicy;
    use mcptest_core::report::{Report, RunSummary, TestFailure, TestResult, TestStatus};

    fn failing(name: &str, message: &str) -> TestResult {
        TestResult {
            name: name.into(),
            status: TestStatus::Fail,
            duration_ms: 1,
            cache_hit: false,
            started_at: None,
            ended_at: None,
            failure: Some(TestFailure {
                message: message.into(),
                diff: None,
                network_diagnostic: None,
            }),
            file: None,
            line: None,
            steps: Vec::new(),
            consensus: None,
            rule_id: None,
            severity: None,
            score: None,
            derived_metrics: Vec::new(),
            natural_language: false,
        }
    }

    fn report_with(tests: Vec<TestResult>) -> Report {
        Report {
            schema_version: "0.1".into(),
            mcptest_version: "test".into(),
            generated_at: String::new(),
            run_id: String::new(),
            summary: RunSummary::from_tests(&tests),
            tests,
            cost: None,
            metadata: None,
            matrix: None,
            protocol_violations: Vec::new(),
        }
    }

    #[test]
    fn an_off_policy_downgrades_nothing() {
        let report = report_with(vec![failing("t", "connection timed out after 30s")]);
        assert_eq!(
            downgrade_flaky_failures(&report, FailurePolicy::default(), true),
            0
        );
    }

    #[test]
    fn a_transient_failure_downgrades_under_the_policy() {
        let report = report_with(vec![failing("t", "connection timed out after 30s")]);
        let policy = FailurePolicy {
            downgrade_transient: true,
            downgrade_external: false,
        };
        assert_eq!(downgrade_flaky_failures(&report, policy, true), 1);
    }

    #[test]
    fn a_code_bug_failure_still_gates_under_the_policy() {
        // A plain assertion failure is a code bug, never downgraded.
        let report = report_with(vec![failing("t", "assertion failed: expected 5 got 4")]);
        let policy = FailurePolicy {
            downgrade_transient: true,
            downgrade_external: true,
        };
        assert_eq!(downgrade_flaky_failures(&report, policy, true), 0);
    }
}
