//! Handler for `mcptest scenario` (replay scenarios against a seeded world).
//!
//! Loads the suite, and for each scenario replays its cassette trajectory
//! through the deterministic world-state engine in `mcptest_core::scenario`.
//! The engine never calls a model, so this runs in CI offline. Each scenario
//! passes when it has no invalid actions, no forbidden transitions, every
//! expected-state path matches, and every `expect:` assertion holds.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use mcptest_agent::cassette;
use mcptest_core::executor::eval_metric_assertion;
use mcptest_core::scenario::{
    report_envelope, run_scenario, Scenario, ScenarioAction, ScenarioReport, Trajectory,
};
use mcptest_core::suite::Assertion;

use crate::cli::args::{ScenarioArgs, ScenarioCommand, ScenarioRunArgs};

/// Dispatch the `mcptest scenario` subcommand.
pub(crate) fn scenario_command(args: ScenarioArgs) -> Result<i32> {
    match args.command {
        ScenarioCommand::Run(a) => run(a),
    }
}

/// Outcome of evaluating one scenario, ready to print or serialize.
///
/// `pub(crate)` so the run-aggregator scenarios lane
/// ([`super::run_live_lanes::ScenariosLane`]) can fold each outcome into a
/// run report row; the lane reads only the public fields and [`Self::passed`].
pub(crate) struct Outcome {
    report: ScenarioReport,
    /// Deterministic-oracle and `expect:` failures; empty means the scenario
    /// passed. `pub(crate)` so the lane can join them into a row failure note.
    pub(crate) failures: Vec<String>,
    /// `Some(reason)` when the scenario was parked with `skip:`; a skipped
    /// scenario still counts as passed. `pub(crate)` for the lane's row mapping.
    pub(crate) skipped: Option<String>,
}

impl Outcome {
    /// True when the scenario passed: either it was skipped or it accumulated
    /// no failures. `pub(crate)` so the lane gates its row on the same rule the
    /// `mcptest scenario` command uses.
    pub(crate) fn passed(&self) -> bool {
        self.skipped.is_some() || self.failures.is_empty()
    }
}

/// Replay every selected scenario and gate on the deterministic oracles.
fn run(args: ScenarioRunArgs) -> Result<i32> {
    let yaml = mcptest_config::loader::load_and_validate(
        &args.suite,
        mcptest_config::loader::LoadOptions::default(),
    )
    .map_err(|e| anyhow::anyhow!("{e}"))?;
    let suite = mcptest_core::suite::parse(&yaml).map_err(|e| anyhow::anyhow!("{e}"))?;
    if suite.scenarios.is_empty() {
        eprintln!(
            "mcptest scenario: {} declares no `scenarios:` block",
            args.suite.display()
        );
        return Ok(1);
    }
    let base_dir = args
        .cassette_dir
        .clone()
        .or_else(|| args.suite.parent().map(Path::to_path_buf))
        .unwrap_or_else(|| PathBuf::from("."));

    let mut outcomes = Vec::new();
    for scenario in &suite.scenarios {
        if !args.names.is_empty() && !args.names.contains(&scenario.name) {
            continue;
        }
        outcomes.push(evaluate(scenario, &base_dir)?);
    }
    if outcomes.is_empty() {
        eprintln!("mcptest scenario: no scenario matched the --name filter");
        return Ok(1);
    }

    if args.json {
        let reports: Vec<&ScenarioReport> = outcomes.iter().map(|o| &o.report).collect();
        println!("{}", serde_json::to_string_pretty(&reports)?);
    } else {
        for outcome in &outcomes {
            print_human(outcome);
        }
    }
    let failed = outcomes.iter().filter(|o| !o.passed()).count();
    Ok(i32::from(failed > 0))
}

/// Build the trajectory for one scenario and run the engine plus assertions.
///
/// `pub(crate)` so the run-aggregator scenarios lane drives the same
/// deterministic engine the `mcptest scenario` command does, keeping a single
/// scenario-evaluation path with no behavioral fork.
pub(crate) fn evaluate(scenario: &Scenario, base_dir: &Path) -> Result<Outcome> {
    if let Some(reason) = &scenario.skip {
        return Ok(Outcome {
            report: empty_report(scenario),
            failures: Vec::new(),
            skipped: Some(reason.clone()),
        });
    }
    let trajectory = load_trajectory(scenario, base_dir)?;
    let report = run_scenario(scenario, &trajectory);
    let failures = collect_failures(&report, &scenario.expect);
    Ok(Outcome {
        report,
        failures,
        skipped: None,
    })
}

/// Resolve and load a scenario's cassette into an engine trajectory.
fn load_trajectory(scenario: &Scenario, base_dir: &Path) -> Result<Trajectory> {
    let rel = scenario.cassette.as_ref().ok_or_else(|| {
        anyhow::anyhow!(
            "scenario `{}` has no `cassette:`; the deterministic CI path needs one",
            scenario.name
        )
    })?;
    let path = base_dir.join(rel);
    let cassette = cassette::load(&path)
        .with_context(|| format!("loading cassette for scenario `{}`", scenario.name))?;
    Ok(trajectory_from_cassette(&cassette))
}

/// Convert a recorded agent cassette into a replay trajectory.
fn trajectory_from_cassette(cassette: &cassette::Cassette) -> Trajectory {
    let trace = &cassette.trace;
    let actions = trace
        .tool_calls
        .iter()
        .enumerate()
        .map(|(i, call)| ScenarioAction {
            name: call.name.clone(),
            server: call.server.clone(),
            args: call.args.clone(),
            error: trace
                .tool_results
                .iter()
                .find(|r| r.call_index == i)
                .is_some_and(|r| r.is_error),
        })
        .collect();
    let final_responses = if trace.conversation.per_turn.is_empty() {
        vec![trace.final_response.clone()]
    } else {
        trace
            .conversation
            .per_turn
            .iter()
            .map(|t| t.final_response.clone())
            .collect()
    };
    Trajectory {
        actions,
        final_responses,
        cost_micros: cost_micros(cassette),
    }
}

/// Estimate the run cost in micro-dollars from the cassette's token totals.
/// Returns `None` when the bundled pricing table has no rate for the model.
fn cost_micros(cassette: &cassette::Cassette) -> Option<u64> {
    let pricing = crate::pipe::pricing::PricingTable::bundled().ok()?;
    if pricing.is_default_rate(&cassette.model) {
        return None;
    }
    let tokens = cassette.trace.conversation.tokens;
    let dollars = pricing.estimate(
        &cassette.model,
        u64::from(tokens.prompt),
        u64::from(tokens.completion),
    );
    Some((dollars * 1_000_000.0).round() as u64)
}

/// Fold the deterministic oracle and the `expect:` assertions into a failure
/// list. An empty list means the scenario passed.
fn collect_failures(report: &ScenarioReport, expect: &[Assertion]) -> Vec<String> {
    let mut failures = Vec::new();
    for invalid in &report.invalid_actions {
        failures.push(format!(
            "invalid action #{} `{}`: {}",
            invalid.index, invalid.tool, invalid.detail
        ));
    }
    for forbidden in &report.forbidden_transitions {
        failures.push(format!(
            "forbidden transition #{} `{}`: {}",
            forbidden.index, forbidden.tool, forbidden.reason
        ));
    }
    for mismatch in &report.state_mismatches {
        failures.push(format!(
            "state `{}` expected {} but world held {}",
            mismatch.path,
            mismatch.expected,
            mismatch
                .actual
                .as_ref()
                .map(ToString::to_string)
                .unwrap_or_else(|| "absent".into())
        ));
    }
    let envelope = report_envelope(report);
    for assertion in expect {
        if let Err(reason) = eval_metric_assertion(&envelope, assertion) {
            failures.push(format!("expect `{}`: {reason}", assertion.target));
        }
    }
    failures
}

/// Render one scenario outcome as a human-readable block.
fn print_human(outcome: &Outcome) {
    let report = &outcome.report;
    if let Some(reason) = &outcome.skipped {
        println!("SKIP {}  ({reason})", report.name);
        return;
    }
    let status = if outcome.passed() { "PASS" } else { "FAIL" };
    println!("{status} {}", report.name);
    println!("  trajectory: {}", report.trajectory.join(" -> "));
    println!(
        "  invalid={} forbidden={} recovery={} escalations={} refusals={}",
        report.invalid_actions.len(),
        report.forbidden_transitions.len(),
        report.recovery_attempts,
        report.escalations,
        report.refusals,
    );
    if let Some(golden) = &report.golden {
        println!(
            "  golden: matched={} (exact={}, alternate={}, penalty={:.2})",
            golden.matched, golden.exact, golden.alternate, golden.score.penalty
        );
    }
    if report.state_diff.is_empty() {
        println!("  state-diff: (no change)");
    } else {
        for change in &report.state_diff {
            println!(
                "  state-diff: {} {} -> {}",
                change.path,
                change
                    .from
                    .as_ref()
                    .map(ToString::to_string)
                    .unwrap_or_else(|| "absent".into()),
                change
                    .to
                    .as_ref()
                    .map(ToString::to_string)
                    .unwrap_or_else(|| "absent".into()),
            );
        }
    }
    if let Some(cost) = report.cost_micros {
        println!("  cost: ${:.6}", cost as f64 / 1_000_000.0);
    }
    if report.judge_evidence.is_some() {
        println!("  judge: rubric configured (deferred to live mode)");
    }
    for failure in &outcome.failures {
        println!("  - {failure}");
    }
}

/// An empty report stand-in for a skipped scenario.
fn empty_report(scenario: &Scenario) -> ScenarioReport {
    ScenarioReport {
        name: scenario.name.clone(),
        turn_count: 0,
        trajectory: Vec::new(),
        invalid_actions: Vec::new(),
        forbidden_transitions: Vec::new(),
        recovery_attempts: 0,
        escalations: 0,
        refusals: 0,
        final_state: scenario.seed.clone(),
        state_diff: Vec::new(),
        state_mismatches: Vec::new(),
        golden: None,
        cost_micros: None,
        judge_evidence: None,
    }
}
