//! Imperative one-shot subcommands for ad-hoc MCP interaction.
//!
//! `mcptest tools`, `mcptest resources`, `mcptest prompts`, and
//! `mcptest capabilities` connect to an MCP server, run the matching spec
//! method, and print the result. This is the curl-of-MCP surface: a user
//! who already has a server running wants to poke at it before authoring a
//! YAML suite. No YAML, no test runner, no reporters; just one call.
//!
//! Each subcommand shares the same protocol stack as `mcptest run` and
//! `mcptest discover`: a streamable HTTP transport, the protocol [`Client`],
//! a single `initialize` handshake, and one or two follow-up requests. Pretty
//! output is the default; `--format json` emits the unredacted wire-format
//! payload for piping into `jq`.

use std::time::Duration;

use anyhow::{Context, Result};
use mcptest_core::protocol::{Client, ProtocolError, ProtocolVersion};
use mcptest_core::transport::{StreamableHttp, StreamableHttpConfig};
use serde_json::{json, Value};

/// Output format for the introspection subcommands.
///
/// Kept as an enum so clap can list the options in `--help` and reject typos
/// before the runner ever connects. Pretty is human-friendly; JSON mirrors
/// the on-wire MCP payload so the user can learn the protocol by reading it.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum IntrospectFormat {
    /// Default tabular text.
    Pretty,
    /// Pretty-printed JSON of the on-wire response payload.
    Json,
}

/// Common arguments for every introspection subcommand.
///
/// The CLI flattens its own clap struct into this plain-data shape so the
/// dispatcher and the tests share one entry point. Auth flags follow the
/// same pattern as `mcptest discover`: a bearer token read from an env var.
#[derive(Debug, Clone)]
pub struct IntrospectArgs {
    /// MCP server endpoint. Required for v1.0 because stdio targets require
    /// the runner's full spawn machinery; this surface is URL-first.
    pub url: String,
    /// Optional bearer token forwarded onto every request as
    /// `Authorization: Bearer <token>`.
    pub bearer_token: Option<String>,
    /// Output format selector.
    pub format: IntrospectFormat,
}

/// Entry point for `mcptest tools`.
///
/// Connects, calls `tools/list`, prints the catalog. The future
/// `tools call <name>` and `tools show <name>` sub-subcommands land as
/// separate variants; v1.0 ships the list operation because it is the one
/// that closes the discoverability gap the README highlights.
pub fn run_tools(args: IntrospectArgs) -> Result<i32> {
    let value = fetch_value(&args, "tools/list").context("tools/list call failed")?;
    match args.format {
        IntrospectFormat::Json => print_json(&value)?,
        IntrospectFormat::Pretty => print_tools_pretty(&value),
    }
    Ok(0)
}

/// Entry point for `mcptest resources`.
pub fn run_resources(args: IntrospectArgs) -> Result<i32> {
    let value = fetch_value(&args, "resources/list").context("resources/list call failed")?;
    match args.format {
        IntrospectFormat::Json => print_json(&value)?,
        IntrospectFormat::Pretty => print_resources_pretty(&value),
    }
    Ok(0)
}

/// Entry point for `mcptest prompts`.
pub fn run_prompts(args: IntrospectArgs) -> Result<i32> {
    let value = fetch_value(&args, "prompts/list").context("prompts/list call failed")?;
    match args.format {
        IntrospectFormat::Json => print_json(&value)?,
        IntrospectFormat::Pretty => print_prompts_pretty(&value),
    }
    Ok(0)
}

/// Entry point for `mcptest capabilities`.
///
/// Differs from the other three: there is no `capabilities/list` method on
/// the wire. The capability block lives in the `initialize` response, so we
/// surface what the handshake already returned instead of issuing a second
/// request.
pub fn run_capabilities(args: IntrospectArgs) -> Result<i32> {
    let outcome = run_handshake_only(&args).context("initialize call failed")?;
    match args.format {
        IntrospectFormat::Json => {
            let value = json!({
                "protocolVersion": outcome.protocol_version,
                "serverInfo": {
                    "name": outcome.server_name,
                    "version": outcome.server_version,
                },
                "capabilities": outcome.capabilities,
            });
            print_json(&value)?;
        }
        IntrospectFormat::Pretty => print_capabilities_pretty(&outcome),
    }
    Ok(0)
}

/// Minimal handshake transcript surfaced by the capabilities subcommand.
#[derive(Debug)]
struct HandshakeOutcome {
    server_name: Option<String>,
    server_version: Option<String>,
    protocol_version: String,
    capabilities: Value,
}

fn run_handshake_only(args: &IntrospectArgs) -> Result<HandshakeOutcome> {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .context("failed to build tokio runtime")?;
    runtime.block_on(async {
        let (client, handle) = build_client(args).await?;
        let outcome = client.initialize(ProtocolVersion::current()).await?;
        let handshake = HandshakeOutcome {
            server_name: outcome.server.name.clone(),
            server_version: outcome.server.version.clone(),
            protocol_version: outcome.negotiated_version.as_wire().to_string(),
            capabilities: outcome.server.capabilities.clone(),
        };
        client.close().await.ok();
        handle.shutdown().await;
        Ok::<_, anyhow::Error>(handshake)
    })
}

fn fetch_value(args: &IntrospectArgs, method: &str) -> Result<Value> {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .context("failed to build tokio runtime")?;
    runtime.block_on(async {
        let (client, handle) = build_client(args).await?;
        client.initialize(ProtocolVersion::current()).await?;
        let value = match client.request(method, json!({})).await {
            Ok(v) => v,
            Err(ProtocolError::ServerError {
                code,
                message,
                data: _,
            }) => {
                // A server without prompts/resources typically replies with
                // a method-not-found error. We surface that as an empty list
                // so the user gets a useful output rather than a stack trace.
                if code == -32601 {
                    empty_for(method)
                } else {
                    client.close().await.ok();
                    handle.shutdown().await;
                    return Err(anyhow::anyhow!("server returned {code}: {message}"));
                }
            }
            Err(other) => {
                client.close().await.ok();
                handle.shutdown().await;
                return Err(anyhow::anyhow!(other));
            }
        };
        client.close().await.ok();
        handle.shutdown().await;
        Ok::<_, anyhow::Error>(value)
    })
}

async fn build_client(
    args: &IntrospectArgs,
) -> Result<(Client, mcptest_core::transport::StreamableHttp)> {
    let config = StreamableHttpConfig {
        url: args.url.clone(),
        bearer_token: args.bearer_token.clone(),
        request_timeout: Duration::from_secs(15),
        connect_timeout: Duration::from_secs(15),
        ..StreamableHttpConfig::default()
    };
    let (transport, handle) = StreamableHttp::connect(config)
        .map_err(|e| anyhow::anyhow!("streamable HTTP setup failed: {e}"))?;
    let client = Client::builder(transport)
        .with_request_timeout(Duration::from_secs(15))
        .build();
    Ok((client, handle))
}

fn empty_for(method: &str) -> Value {
    let key = match method {
        "tools/list" => "tools",
        "resources/list" => "resources",
        "prompts/list" => "prompts",
        _ => return json!({}),
    };
    json!({ key: [] })
}

fn print_json(value: &Value) -> Result<()> {
    let body = serde_json::to_string_pretty(value)?;
    println!("{body}");
    Ok(())
}

pub(crate) fn print_tools_pretty(value: &Value) {
    let tools = value.get("tools").and_then(Value::as_array);
    let Some(tools) = tools else {
        println!("(no tools advertised)");
        return;
    };
    if tools.is_empty() {
        println!("(no tools advertised)");
        return;
    }
    let width = tools
        .iter()
        .filter_map(|t| t.get("name").and_then(Value::as_str).map(str::len))
        .max()
        .unwrap_or(4)
        .max(4);
    println!("{:<width$}  DESCRIPTION", "NAME", width = width);
    for tool in tools {
        let name = tool.get("name").and_then(Value::as_str).unwrap_or("");
        let desc = tool
            .get("description")
            .and_then(Value::as_str)
            .unwrap_or("");
        println!("{name:<width$}  {desc}", width = width);
    }
}

pub(crate) fn print_resources_pretty(value: &Value) {
    let items = value.get("resources").and_then(Value::as_array);
    let Some(items) = items else {
        println!("(no resources advertised)");
        return;
    };
    if items.is_empty() {
        println!("(no resources advertised)");
        return;
    }
    println!("URI                                          NAME              MIME TYPE");
    for item in items {
        let uri = item.get("uri").and_then(Value::as_str).unwrap_or("");
        let name = item.get("name").and_then(Value::as_str).unwrap_or("");
        let mime = item.get("mimeType").and_then(Value::as_str).unwrap_or("");
        println!("{uri:<44}  {name:<16}  {mime}");
    }
}

pub(crate) fn print_prompts_pretty(value: &Value) {
    let items = value.get("prompts").and_then(Value::as_array);
    let Some(items) = items else {
        println!("(no prompts advertised)");
        return;
    };
    if items.is_empty() {
        println!("(no prompts advertised)");
        return;
    }
    println!("NAME                ARGUMENTS");
    for prompt in items {
        let name = prompt.get("name").and_then(Value::as_str).unwrap_or("");
        let arg_names: Vec<String> = prompt
            .get("arguments")
            .and_then(Value::as_array)
            .map(|arr| {
                arr.iter()
                    .filter_map(|a| a.get("name").and_then(Value::as_str).map(str::to_string))
                    .collect()
            })
            .unwrap_or_default();
        println!("{name:<20}{}", arg_names.join(", "));
    }
}

fn print_capabilities_pretty(outcome: &HandshakeOutcome) {
    print_capabilities(
        outcome.server_name.as_deref(),
        outcome.server_version.as_deref(),
        &outcome.protocol_version,
        &outcome.capabilities,
    );
}

/// Render a server capability block as the pretty `capabilities` view. Shared
/// by `mcptest capabilities` and the `inspect` REPL so both surface the same
/// shape from the `initialize` handshake.
pub(crate) fn print_capabilities(
    server_name: Option<&str>,
    server_version: Option<&str>,
    protocol_version: &str,
    capabilities: &Value,
) {
    let name = server_name.unwrap_or("(unknown)");
    let version = server_version.unwrap_or("(unknown)");
    println!("Server: {name} {version}");
    println!("Protocol version: {protocol_version}");
    println!("Capabilities:");
    if let Some(obj) = capabilities.as_object() {
        if obj.is_empty() {
            println!("  (none advertised)");
        }
        for (key, body) in obj {
            // Extensions get their own labelled section below so the
            // reverse-DNS keys do not flatten in next to the simple
            // capability keys; keep them out of the main loop here.
            if key == "extensions" {
                continue;
            }
            let detail = match body {
                Value::Object(map) => {
                    let bits: Vec<String> = map
                        .iter()
                        .map(|(k, v)| format!("{k}: {}", short_value(v)))
                        .collect();
                    if bits.is_empty() {
                        String::new()
                    } else {
                        format!(" ({})", bits.join(", "))
                    }
                }
                other => format!(" = {}", short_value(other)),
            };
            println!("  {key}{detail}");
        }
        // OSS-side extension advertisement detection. Surfaces
        // any reverse-DNS id under `extensions` with its kind label so an
        // operator sees what the server advertises even without a
        // matching pack installed.
        let detected = mcptest_core::extensions::advertised_extensions(capabilities);
        if !detected.is_empty() {
            println!("Extensions:");
            for adv in detected {
                println!("  {}", adv.label());
            }
        }
    } else {
        println!("  (capabilities block was not a JSON object)");
    }
}

fn short_value(v: &Value) -> String {
    match v {
        Value::Null => "null".to_string(),
        Value::Bool(b) => b.to_string(),
        Value::Number(n) => n.to_string(),
        Value::String(s) => s.clone(),
        Value::Array(_) | Value::Object(_) => v.to_string(),
    }
}

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

    #[test]
    fn empty_for_known_method_returns_named_array() {
        let value = empty_for("tools/list");
        assert!(value["tools"].as_array().unwrap().is_empty());
    }

    #[test]
    fn print_tools_pretty_handles_empty_catalog() {
        // Smoke test for the no-tools branch. We can not capture stdout from
        // here, but the call must not panic.
        print_tools_pretty(&json!({ "tools": [] }));
    }

    #[test]
    fn short_value_renders_primitives_inline() {
        assert_eq!(short_value(&json!(true)), "true");
        assert_eq!(short_value(&json!(7)), "7");
        assert_eq!(short_value(&json!("hi")), "hi");
        assert_eq!(short_value(&Value::Null), "null");
    }
}
