//! `mcptest propose`: execute one tool call, observe the response, and print
//! a proposed `expect:` block derived only from observation (WOR-1262).
//!
//! Why this exists: sharpening a scaffolded shape check into real assertions
//! by hand means copying values out of a response, which invites typos and
//! invented expectations. This subcommand is the authoring-time accept loop:
//! it calls the tool (twice for tools classified read-only, so volatile
//! leaves are detected and excluded), hands the observations to
//! `mcptest_core::propose`, and prints the paste-ready YAML block to stdout
//! with the caveats on stderr. Safety reuses `exec_policy`: a mutating tool
//! gets exactly one call, and a destructive tool is refused unless
//! `--execute-destructive` is passed (then exactly one call).

use std::time::{Duration, Instant};

use anyhow::{anyhow, bail, Context, Result};
use serde_json::{json, Value};

use mcptest_core::exec_policy::{
    classify_descriptor, CallContext, Decision, ExecutionPolicy, ToolClass,
};
use mcptest_core::propose::{propose_test, Observation, ProposalInput};
use mcptest_core::protocol::{Client, ProtocolVersion};
use mcptest_core::transport::{StdioConfig, StdioTransport, StreamableHttp, StreamableHttpConfig};

/// Server key the printed test references; the user declares this entry
/// under `servers:` when pasting the block. Matches the scaffold verbs so
/// the two outputs compose into one suite without renaming.
const PROPOSE_SERVER_NAME: &str = "target";

/// Per-request timeout for the handshake, the listing, and each observed
/// call. The observed durations feed the proposed latency budget, so the
/// timeout only has to bound a hung server.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);

/// Plain-data inputs for one `mcptest propose` invocation. The clap struct
/// lowers into this so the engine stays free of clap and tests can build it
/// directly.
#[derive(Debug, Clone)]
pub struct ProposeRunArgs {
    /// Tool to call on the target server.
    pub tool: String,
    /// Arguments for the call as a JSON object string (`--args '{...}'`).
    pub args_json: Option<String>,
    /// HTTP endpoint of the target server (`--url`).
    pub url: Option<String>,
    /// Bearer token resolved from `--bearer-token-env`, URL targets only.
    pub bearer_token: Option<String>,
    /// Argv of a local stdio server, already split (`--server-command`).
    pub server_command: Option<Vec<String>>,
    /// Observe one call only; skips the stability probe (`--single-call`).
    pub single_call: bool,
    /// Allow exactly one live call to a destructive-classified tool
    /// (`--execute-destructive`).
    pub execute_destructive: bool,
}

/// Run the propose flow: observe, derive, print. The YAML block goes to
/// stdout (pipe it into a suite); notes and the excluded volatile paths go
/// to stderr so redirection captures a clean block.
pub fn run_propose(args: ProposeRunArgs) -> Result<i32> {
    let call_args = parse_args_object(args.args_json.as_deref())?;
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .context("failed to build tokio runtime")?;
    let (observations, notes) = runtime.block_on(observe(&args, &call_args))?;
    let first = observations
        .first()
        .ok_or_else(|| anyhow!("no call was observed"))?;
    let input = ProposalInput {
        server: PROPOSE_SERVER_NAME.to_string(),
        tool: args.tool.clone(),
        args: call_args,
    };
    let proposal = propose_test(&input, first, observations.get(1));
    for note in notes.iter().chain(proposal.notes.iter()) {
        eprintln!("note: {note}");
    }
    for path in &proposal.excluded_volatile {
        eprintln!("excluded volatile leaf: {path}");
    }
    eprintln!(
        "calls made: {}. Paste the block below under `tools:` in a suite that \
         declares a `{PROPOSE_SERVER_NAME}` server.",
        observations.len()
    );
    print!("{}", proposal.yaml);
    Ok(0)
}

/// Parse `--args` as a JSON object, defaulting to `{}`. Strict about the
/// shape because `tools/call` arguments are an object on the wire.
fn parse_args_object(raw: Option<&str>) -> Result<Value> {
    let Some(raw) = raw else {
        return Ok(json!({}));
    };
    let value: Value = serde_json::from_str(raw).context("--args was not valid JSON")?;
    if !value.is_object() {
        bail!("--args must be a JSON object (the tools/call arguments)");
    }
    Ok(value)
}

/// Connect to the target, classify the tool from its live catalog, and make
/// the planned number of timed calls. One session serves the listing and the
/// calls so classification and observation cannot drift apart.
async fn observe(
    args: &ProposeRunArgs,
    call_args: &Value,
) -> Result<(Vec<Observation>, Vec<String>)> {
    match (&args.url, &args.server_command) {
        (Some(url), None) => {
            let cfg = StreamableHttpConfig {
                url: url.clone(),
                bearer_token: args.bearer_token.clone(),
                request_timeout: REQUEST_TIMEOUT,
                connect_timeout: Duration::from_secs(15),
                ..StreamableHttpConfig::default()
            };
            let (transport, handle) = StreamableHttp::connect(cfg)
                .map_err(|e| anyhow!("streamable HTTP setup failed: {e}"))?;
            let client = Client::builder(transport)
                .with_request_timeout(REQUEST_TIMEOUT)
                .build();
            let result = observe_calls(&client, args, call_args).await;
            client.close().await.ok();
            handle.shutdown().await;
            result
        }
        (None, Some(argv)) => {
            let Some(program) = argv.first() else {
                bail!("--server-command must not be empty");
            };
            let cfg = StdioConfig {
                command: program.clone(),
                args: argv[1..].to_vec(),
                ..StdioConfig::default()
            };
            let (transport, handle) = StdioTransport::spawn(cfg)
                .map_err(|e| anyhow!("spawn stdio target failed: {e}"))?;
            let client = Client::builder(transport)
                .with_request_timeout(REQUEST_TIMEOUT)
                .build();
            let result = observe_calls(&client, args, call_args).await;
            // Best-effort teardown: the observations are already in hand.
            client.close().await.ok();
            let _ = handle.shutdown().await;
            result
        }
        // clap enforces exactly-one-of; these arms keep the engine honest
        // when called directly from tests.
        (Some(_), Some(_)) => bail!("pass exactly one of --url or --server-command, not both"),
        (None, None) => bail!("a target is required: pass --url or --server-command"),
    }
}

/// Handshake, classify, then call. Classification reads the live
/// `tools/list` descriptor rather than trusting the flag surface, so a
/// destructive tool cannot dodge the `--execute-destructive` gate.
async fn observe_calls(
    client: &Client,
    args: &ProposeRunArgs,
    call_args: &Value,
) -> Result<(Vec<Observation>, Vec<String>)> {
    client
        .initialize(ProtocolVersion::current())
        .await
        .map_err(|e| anyhow!("initialize handshake failed: {e}"))?;
    let listing = client
        .request("tools/list", json!({}))
        .await
        .map_err(|e| anyhow!("tools/list failed: {e}"))?;
    let tools = listing
        .get("tools")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let descriptor = tools
        .iter()
        .find(|t| t.get("name").and_then(Value::as_str) == Some(args.tool.as_str()))
        .ok_or_else(|| {
            let names: Vec<&str> = tools
                .iter()
                .filter_map(|t| t.get("name").and_then(Value::as_str))
                .collect();
            anyhow!(
                "tool `{}` is not in the target's catalog (available: {})",
                args.tool,
                names.join(", ")
            )
        })?;
    let class = classify_descriptor(descriptor);
    let mut notes = Vec::new();
    let calls = planned_calls(class, args, &mut notes)?;
    let mut observations = Vec::with_capacity(calls);
    for _ in 0..calls {
        let started = Instant::now();
        let result = client
            .request(
                "tools/call",
                json!({ "name": args.tool, "arguments": call_args }),
            )
            .await
            .map_err(|e| anyhow!("tools/call `{}` failed: {e}", args.tool))?;
        observations.push(Observation {
            result,
            duration: started.elapsed(),
        });
    }
    Ok((observations, notes))
}

/// How many calls the safety policy allows: two for read-only tools (the
/// stability probe), one for mutating or destructive-with-override tools,
/// refusal for destructive tools without `--execute-destructive`.
fn planned_calls(
    class: ToolClass,
    args: &ProposeRunArgs,
    notes: &mut Vec<String>,
) -> Result<usize> {
    let policy = ExecutionPolicy {
        execute_destructive: args.execute_destructive,
        ..ExecutionPolicy::default()
    };
    match policy.decide(class, CallContext::MustExecute) {
        Decision::Execute => {
            if args.single_call {
                notes.push(
                    "--single-call: the stability probe was skipped, so no exact-value \
                     assertions are proposed"
                        .to_string(),
                );
                Ok(1)
            } else {
                Ok(2)
            }
        }
        Decision::ExecuteOnce => {
            notes.push(format!(
                "tool classified {}: called exactly once (a second stability call \
                 could itself be a side effect), so no exact-value assertions are proposed",
                class_label(class)
            ));
            Ok(1)
        }
        Decision::Refuse { reason } => bail!(
            "propose refused: {reason}. Re-run with --execute-destructive to allow \
             exactly one live call to this tool."
        ),
        // `MustExecute` never yields GenerateOnly; refuse identically if the
        // policy ever changes shape rather than guessing a fallback.
        Decision::GenerateOnly { .. } => {
            bail!("propose refused: tool is destructive and --execute-destructive is not set.")
        }
    }
}

/// Human label for a tool class, for stderr notes.
fn class_label(class: ToolClass) -> &'static str {
    match class {
        ToolClass::ReadOnly => "read-only",
        ToolClass::ReadOnlyPresumed => "presumed read-only",
        ToolClass::Mutating => "mutating",
        ToolClass::Destructive => "destructive",
    }
}

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

    fn run_args() -> ProposeRunArgs {
        ProposeRunArgs {
            tool: "get_status".to_string(),
            args_json: None,
            url: None,
            bearer_token: None,
            server_command: Some(vec!["./srv".to_string()]),
            single_call: false,
            execute_destructive: false,
        }
    }

    #[test]
    fn args_default_to_an_empty_object_and_reject_non_objects() {
        assert_eq!(parse_args_object(None).unwrap(), json!({}));
        assert_eq!(
            parse_args_object(Some(r#"{"q":"x"}"#)).unwrap(),
            json!({ "q": "x" })
        );
        assert!(parse_args_object(Some("[1]")).is_err());
        assert!(parse_args_object(Some("not json")).is_err());
    }

    #[test]
    fn read_only_tools_plan_two_calls_and_single_call_plans_one() {
        let mut notes = Vec::new();
        assert_eq!(
            planned_calls(ToolClass::ReadOnlyPresumed, &run_args(), &mut notes).unwrap(),
            2
        );
        let single = ProposeRunArgs {
            single_call: true,
            ..run_args()
        };
        assert_eq!(
            planned_calls(ToolClass::ReadOnly, &single, &mut notes).unwrap(),
            1
        );
        assert!(notes.iter().any(|n| n.contains("--single-call")));
    }

    #[test]
    fn mutating_tools_plan_exactly_one_call() {
        let mut notes = Vec::new();
        assert_eq!(
            planned_calls(ToolClass::Mutating, &run_args(), &mut notes).unwrap(),
            1
        );
        assert!(notes.iter().any(|n| n.contains("mutating")));
    }

    #[test]
    fn destructive_tools_require_the_execute_destructive_flag() {
        let mut notes = Vec::new();
        let err = planned_calls(ToolClass::Destructive, &run_args(), &mut notes).unwrap_err();
        assert!(err.to_string().contains("--execute-destructive"));
        let with_flag = ProposeRunArgs {
            execute_destructive: true,
            ..run_args()
        };
        assert_eq!(
            planned_calls(ToolClass::Destructive, &with_flag, &mut notes).unwrap(),
            1
        );
    }
}
