//! `mcptest discover` introspects an MCP server.
//!
//! Two modes:
//!
//! * The original: connect via streamable HTTP, run `initialize`,
//!   call `tools/list`, `resources/list`, `prompts/list`, then scaffold a
//!   starter `tests.yaml`.
//! * The well-known mode: fetch
//!   `.well-known/mcp.json` from a base URL and print or write the parsed
//!   `server:` block. Wires `mcptest_core::discovery::fetch` +
//!   `NetworkDiagnostic::render_pretty` so a failure surfaces a labelled
//!   `[LAYER]` line plus a one-line hint.

use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::{Context, Result};
use mcptest_core::discovery::{
    fetch as fetch_well_known, infer_dependency_graph, render_mermaid, render_table,
    to_server_yaml, DiscoveryError,
};
use mcptest_core::protocol::{Client, ProtocolError, ProtocolVersion};
use mcptest_core::transport::{StreamableHttp, StreamableHttpConfig};
use serde_json::{json, Value};

/// Discovered surface for one server.
///
/// Mirrors the trio of MCP "list" calls. Each field is whatever the server
/// returned for that namespace, raw and untouched, so the scaffolder can
/// dump it without re-parsing.
#[derive(Debug, Default)]
pub struct DiscoveredSurface {
    /// Items returned from `tools/list`.
    pub tools: Vec<Value>,
    /// Items returned from `resources/list`.
    pub resources: Vec<Value>,
    /// Items returned from `prompts/list`.
    pub prompts: Vec<Value>,
}

/// Pretty-print a discovered surface to stderr.
///
/// Kept separate from [`scaffold_tests_yaml`] so tests can pin the on-disk
/// format independently of the human-facing render.
pub fn report_surface(server_name: &str, surface: &DiscoveredSurface) {
    eprintln!("Discovered surface for `{server_name}`:");
    eprintln!("  tools:     {}", surface.tools.len());
    eprintln!("  resources: {}", surface.resources.len());
    eprintln!("  prompts:   {}", surface.prompts.len());
    for tool in &surface.tools {
        if let Some(name) = tool.get("name").and_then(Value::as_str) {
            eprintln!("    tool: {name}");
        }
    }
}

/// Escape a value for embedding inside a double-quoted YAML scalar. Only
/// backslash and double-quote need escaping for the scaffold's quoted fields;
/// without this a tool name or URL containing a quote would emit invalid YAML.
fn yaml_dq(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

/// Build the body of the scaffolded `tests.yaml` from a discovered surface.
///
/// Pure string assembly so the test can pin the output verbatim without
/// touching the filesystem. The output is YAML, with comments pointing the
/// user at the obvious next steps.
pub fn scaffold_tests_yaml(
    server_name: &str,
    server_url: &str,
    surface: &DiscoveredSurface,
) -> String {
    let mut out = String::new();
    out.push_str("# yaml-language-server: $schema=https://mcptest.sh/schema/v1.json\n");
    out.push_str("#\n");
    out.push_str("# Scaffolded by `mcptest discover`.\n");
    out.push_str("#\n");
    out.push_str("# Next steps:\n");
    out.push_str("#   1. Replace the placeholder args below with real inputs for each tool.\n");
    out.push_str("#   2. Add `expect:` blocks asserting on `result.content` shape.\n");
    out.push_str("#   3. Run `mcptest run tests.yaml` to exercise the smoke suite.\n");
    out.push_str("\nservers:\n");
    out.push_str(&format!("  {server_name}:\n"));
    out.push_str(&format!("    url: \"{}\"\n", yaml_dq(server_url)));
    if surface.tools.is_empty() {
        out.push_str("\n# No tools were advertised by the server.\n");
        return out;
    }
    out.push_str("\ntools:\n");
    for tool in &surface.tools {
        let name = tool
            .get("name")
            .and_then(Value::as_str)
            .unwrap_or("unnamed_tool");
        let qname = yaml_dq(name);
        out.push_str(&format!("  - name: \"smoke: {qname}\"\n"));
        out.push_str(&format!("    server: {server_name}\n"));
        out.push_str(&format!("    tool: \"{qname}\"\n"));
        out.push_str("    args: {}\n");
        out.push_str("    expect:\n");
        out.push_str("      - target: \"error\"\n");
        out.push_str("        matcher:\n");
        out.push_str("          exact: null\n");
        out.push_str(&format!(
            "        message: \"{qname} should not return an error\"\n"
        ));
    }
    out
}

/// Connect to the server at `url`, run `initialize`, then call the three
/// `list` endpoints. Returns the merged surface or a `ProtocolError`.
///
/// `bearer_token` is forwarded verbatim onto every request. The handshake
/// times out at 10s; per-call timeouts default to 10s too. The runner uses
/// stricter budgets, but discover is a one-shot probe.
pub async fn probe_server(
    url: &str,
    bearer_token: Option<String>,
) -> Result<DiscoveredSurface, ProtocolError> {
    let config = StreamableHttpConfig {
        url: url.to_owned(),
        bearer_token,
        request_timeout: Duration::from_secs(10),
        connect_timeout: Duration::from_secs(10),
        ..StreamableHttpConfig::default()
    };
    let (transport, handle) = StreamableHttp::connect(config).map_err(|e| {
        ProtocolError::TransportClosed(format!("streamable HTTP setup failed: {e}"))
    })?;
    let client = Client::builder(transport)
        .with_request_timeout(Duration::from_secs(10))
        .build();
    client.initialize(ProtocolVersion::current()).await?;

    let surface = DiscoveredSurface {
        tools: list_items(&client, "tools/list", "tools").await?,
        resources: list_items(&client, "resources/list", "resources").await?,
        prompts: list_items(&client, "prompts/list", "prompts").await?,
    };

    client.close().await.ok();
    handle.shutdown().await;
    Ok(surface)
}

/// Call one `*/list` endpoint and extract the named array.
///
/// Servers that do not implement a list endpoint typically respond with a
/// JSON-RPC error; we treat that as an empty list rather than failing the
/// whole probe so a server with no `resources` still scaffolds tools.
async fn list_items(
    client: &Client,
    method: &str,
    array_key: &str,
) -> Result<Vec<Value>, ProtocolError> {
    let response = match client.request(method, json!({})).await {
        Ok(v) => v,
        Err(ProtocolError::ServerError { .. }) => return Ok(Vec::new()),
        Err(other) => return Err(other),
    };
    let items = response
        .get(array_key)
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    Ok(items)
}

/// Synchronous entry point for `mcptest discover <server>`.
///
/// Spins a single-threaded tokio runtime for the async probe. When `graph` is
/// set it prints the inferred dependency graph and returns; otherwise it writes
/// the scaffolded YAML to `output_path`. Returns the desired exit code.
pub fn run_discover(
    server_arg: &str,
    output_path: &Path,
    bearer_token: Option<String>,
    graph: bool,
) -> Result<i32> {
    let (server_name, url) = split_server_arg(server_arg);
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .context("failed to build tokio runtime")?;
    let surface = match runtime.block_on(probe_server(&url, bearer_token)) {
        Ok(s) => s,
        Err(err) => {
            eprintln!("mcptest discover: failed to probe `{url}`: {err}");
            return Ok(1);
        }
    };
    report_surface(&server_name, &surface);
    if graph {
        print!("{}", render_dependency_graph(&surface));
        return Ok(0);
    }
    let body = scaffold_tests_yaml(&server_name, &url, &surface);
    write_tests_file(output_path, &body)?;
    eprintln!("Wrote {}", output_path.display());
    Ok(0)
}

/// Render the static dependency graph for a probed surface as Mermaid plus a
/// text table, separated by a blank line. Pure string assembly so the format
/// can be pinned in tests without a live probe.
fn render_dependency_graph(surface: &DiscoveredSurface) -> String {
    let graph = infer_dependency_graph(&surface.tools);
    let mut out = render_mermaid(&graph);
    out.push('\n');
    out.push_str(&render_table(&graph));
    out
}

/// Entry point for `mcptest discover --url <base>`.
///
/// Fetches `<base>/.well-known/mcp.json` via [`fetch_well_known`], then
/// either prints the resulting `server:` YAML block to stdout or merges
/// it into `mcptest.local.yml` when `write_path` is set.
///
/// Returns `0` on success, `1` on any failure. Network failures render
/// through [`mcptest_core::network::NetworkDiagnostic::render_pretty`] so
/// the operator gets a `[LAYER] url: message` plus a one-line hint.
pub fn run_discover_url(base_url: &str, write_path: Option<&Path>) -> Result<i32> {
    let doc = match fetch_well_known(base_url) {
        Ok(d) => d,
        Err(err) => {
            render_discovery_error(base_url, &err);
            return Ok(1);
        }
    };
    let yaml = to_server_yaml(&doc);
    match write_path {
        Some(path) => {
            fs::write(path, &yaml)
                .with_context(|| format!("failed to write {}", path.display()))?;
            eprintln!("Wrote {}", path.display());
        }
        None => {
            print!("{yaml}");
        }
    }
    Ok(0)
}

/// Render a [`DiscoveryError`] to stderr with the layered diagnostic when
/// available.
fn render_discovery_error(base_url: &str, err: &DiscoveryError) {
    match err.diagnostic() {
        Some(diag) => {
            eprintln!("mcptest discover --url {base_url}");
            eprintln!("{}", diag.render_pretty());
        }
        None => {
            eprintln!("mcptest discover --url {base_url}: {err}");
        }
    }
}

/// Pick a friendly server name out of the `<server>` argument.
///
/// We accept either a bare URL ("https://...", "http://...") or a
/// `name=url` form. Bare URLs become `discovered` so the YAML output is
/// still valid.
fn split_server_arg(raw: &str) -> (String, String) {
    if let Some((name, url)) = raw.split_once('=') {
        if name.starts_with("http://") || name.starts_with("https://") {
            // The user wrote a URL with `=` in the query string, not a
            // name=url pair. Treat the whole thing as a URL.
            return ("discovered".to_string(), raw.to_string());
        }
        return (name.to_string(), url.to_string());
    }
    ("discovered".to_string(), raw.to_string())
}

/// Create any missing parents and write `body` to `path`.
fn write_tests_file(path: &Path, body: &str) -> Result<PathBuf> {
    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }
    }
    fs::write(path, body).with_context(|| format!("failed to write {}", path.display()))?;
    Ok(path.to_path_buf())
}

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

    #[test]
    fn scaffold_with_two_tools_emits_smoke_tests() {
        let surface = DiscoveredSurface {
            tools: vec![json!({"name": "alpha"}), json!({"name": "beta"})],
            ..DiscoveredSurface::default()
        };
        let body = scaffold_tests_yaml("svr", "https://example.test/mcp", &surface);
        assert!(body.contains("smoke: alpha"));
        assert!(body.contains("smoke: beta"));
        assert!(body.contains("https://example.test/mcp"));
    }

    #[test]
    fn scaffold_escapes_quotes_so_the_yaml_stays_valid() {
        let surface = DiscoveredSurface {
            tools: vec![json!({"name": "wei\"rd"})],
            ..DiscoveredSurface::default()
        };
        let body = scaffold_tests_yaml("svr", "https://x.test/\"q\"", &surface);
        // The embedded quote is backslash-escaped inside the scalar.
        assert!(body.contains("tool: \"wei\\\"rd\""));
        // And the whole document still parses as YAML (the regression: an
        // unescaped quote produced a malformed scaffold).
        let parsed: serde_yaml::Value = serde_yaml::from_str(&body).unwrap();
        assert!(parsed.get("tools").is_some());
    }

    #[test]
    fn scaffold_with_no_tools_still_emits_servers_block() {
        let surface = DiscoveredSurface::default();
        let body = scaffold_tests_yaml("svr", "https://example.test/mcp", &surface);
        assert!(body.contains("servers:"));
        assert!(body.contains("No tools were advertised"));
    }

    #[test]
    fn split_server_arg_accepts_bare_url() {
        let (name, url) = split_server_arg("https://example.test/mcp");
        assert_eq!(name, "discovered");
        assert_eq!(url, "https://example.test/mcp");
    }

    #[test]
    fn split_server_arg_accepts_name_equals_url() {
        let (name, url) = split_server_arg("local=http://127.0.0.1:8080/mcp");
        assert_eq!(name, "local");
        assert_eq!(url, "http://127.0.0.1:8080/mcp");
    }

    #[test]
    fn discover_url_returns_one_on_unreachable_endpoint() {
        // Bind a port and drop the listener so any connect attempt fails.
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        drop(listener);
        let url = format!("http://127.0.0.1:{port}");
        let code = run_discover_url(&url, None).unwrap();
        assert_eq!(code, 1);
    }
}
