//! Interactive `mcptest inspect` REPL.
//!
//! Connects to one MCP server (stdio command or streamable-HTTP URL), runs the
//! `initialize` handshake, and drops into a line-oriented REPL so a developer
//! can explore the server live: list and call tools, read resources, get
//! prompts, view the capability block, and scaffold a starter suite from the
//! session. It is the interactive sibling of the one-shot `tools` /
//! `resources` / `prompts` / `capabilities` / `discover` subcommands, and it
//! adds the two things those cannot do one-shot: a live notifications view and
//! client-side fulfillment of server-initiated requests (sampling, elicitation,
//! roots) so a server that drives the client can be exercised end to end.
//!
//! Terminal-only by design (OSS scope): the REPL reads commands from stdin, so
//! a piped script drives it the same way an interactive session does.

use std::io::{BufRead, IsTerminal, Write};
use std::sync::{Arc, Mutex};
use std::time::Duration;

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

use super::discover::{scaffold_tests_yaml, DiscoveredSurface};
use super::introspect::{
    print_capabilities, print_prompts_pretty, print_resources_pretty, print_tools_pretty,
};

/// Where the REPL connects.
#[derive(Debug, Clone)]
pub enum InspectTarget {
    /// Streamable-HTTP server at `url`, optionally with a bearer token.
    Url {
        /// MCP endpoint URL.
        url: String,
        /// Resolved bearer token (already read from the env var by the caller).
        bearer_token: Option<String>,
    },
    /// Stdio server spawned from `command` plus `args`.
    Stdio {
        /// Program to execute.
        command: String,
        /// Arguments after the program name.
        args: Vec<String>,
    },
}

/// Plain-data arguments for [`run_inspect`], built from the CLI struct.
#[derive(Debug, Clone)]
pub struct InspectArgs {
    /// The server to connect to.
    pub target: InspectTarget,
}

/// Live log of server-initiated notifications, shared between the inbound pump
/// (which appends as they arrive) and the `notifications` REPL command (which
/// reads the history back).
type NotificationLog = Arc<Mutex<Vec<(String, Value)>>>;

/// Connect, run the REPL until EOF or `quit`, then shut the connection down.
pub fn run_inspect(args: InspectArgs) -> Result<i32> {
    // A multi-thread runtime keeps the Client's inbound pump running on a
    // worker thread while the main thread blocks reading stdin, so server
    // notifications and requests are serviced live, not only between commands.
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()
        .context("failed to build tokio runtime")?;

    let log: NotificationLog = Arc::new(Mutex::new(Vec::new()));
    let session = runtime.block_on(connect(&args.target, Arc::clone(&log)))?;
    let Session {
        client,
        conn,
        banner,
    } = session;
    println!("{banner}");
    println!("Type `help` for commands, `quit` to exit.");

    let interactive = std::io::stdin().is_terminal();
    let stdin = std::io::stdin();
    let mut lines = stdin.lock().lines();
    loop {
        if interactive {
            print!("mcptest> ");
            let _ = std::io::stdout().flush();
        }
        let Some(line) = lines.next() else { break };
        let line = line.context("failed to read from stdin")?;
        let command = parse_command(&line);
        let flow = runtime.block_on(dispatch(&client, command, &log));
        if matches!(flow, Flow::Quit) {
            break;
        }
    }

    runtime.block_on(async {
        client.close().await.ok();
        conn.shutdown().await;
    });
    Ok(0)
}

/// A live connection plus the data the REPL needs to start.
struct Session {
    client: Client,
    conn: Conn,
    banner: String,
}

/// Owns the transport handle so the REPL can shut it down cleanly on exit.
// The stdio transport handle is larger than the HTTP one (more so on Windows,
// where it trips `large_enum_variant`). There is one `Conn` per REPL session,
// so the variant-size spread does not matter; allow the lint.
#[allow(clippy::large_enum_variant)]
enum Conn {
    Stdio(StdioTransport),
    Http(StreamableHttp),
}

impl Conn {
    async fn shutdown(self) {
        match self {
            Conn::Stdio(h) => {
                h.shutdown().await.ok();
            }
            Conn::Http(h) => h.shutdown().await,
        }
    }
}

/// Build the transport for `target`, attach the notification + server-request
/// handlers, advertise the client capabilities the REPL fulfills, and run the
/// `initialize` handshake.
async fn connect(target: &InspectTarget, log: NotificationLog) -> Result<Session> {
    let (transport, conn) = match target {
        InspectTarget::Url { url, bearer_token } => {
            let cfg = StreamableHttpConfig {
                url: url.clone(),
                bearer_token: bearer_token.clone(),
                request_timeout: Duration::from_secs(30),
                connect_timeout: Duration::from_secs(15),
                ..StreamableHttpConfig::default()
            };
            let (t, h) = StreamableHttp::connect(cfg)
                .map_err(|e| anyhow::anyhow!("streamable HTTP setup failed: {e}"))?;
            (t, Conn::Http(h))
        }
        InspectTarget::Stdio { command, args } => {
            let cfg = StdioConfig {
                command: command.clone(),
                args: args.clone(),
                env: Default::default(),
                working_dir: None,
                shutdown_timeout: Duration::from_secs(2),
            };
            let (t, h) = StdioTransport::spawn(cfg)
                .map_err(|e| anyhow::anyhow!("failed to spawn `{command}`: {e}"))?;
            (t, Conn::Stdio(h))
        }
    };

    let client = Client::builder(transport)
        .with_request_timeout(Duration::from_secs(30))
        .with_client_capabilities(client_capabilities())
        .with_notification_handler(notification_handler(log))
        .with_server_request_handler(server_request_handler())
        .build();

    let outcome = client
        .initialize(ProtocolVersion::current())
        .await
        .context("MCP initialize handshake failed")?;
    let name = outcome.server.name.as_deref().unwrap_or("(unknown)");
    let version = outcome.server.version.as_deref().unwrap_or("");
    let banner = format!(
        "Connected to {name} {version} (MCP {})",
        outcome.negotiated_version.as_wire()
    );
    Ok(Session {
        client,
        conn,
        banner,
    })
}

/// The capabilities the REPL advertises during `initialize`. We claim every
/// client-side feature the REPL fulfills so a server that gates on them will
/// actually drive the client during the session.
fn client_capabilities() -> ClientCapabilities {
    ClientCapabilities {
        capabilities: json!({
            "sampling": {},
            "elicitation": {},
            "roots": { "listChanged": false },
        }),
        ..ClientCapabilities::default()
    }
}

/// Build the notification handler: print each server notification live and
/// append it to the shared log so `notifications` can replay the history.
fn notification_handler(log: NotificationLog) -> Arc<dyn Fn(String, Value) + Send + Sync> {
    Arc::new(move |method, params| {
        println!("\n<- notification {method}  {}", compact(&params));
        if let Ok(mut log) = log.lock() {
            log.push((method, params));
        }
    })
}

/// Build the server-request handler: print the request, then return the
/// canned fulfillment from [`fulfill_server_request`].
fn server_request_handler() -> mcptest_core::protocol::ServerRequestHandler {
    Arc::new(move |method, params| {
        Box::pin(async move {
            println!("\n<- server request {method}  {}", compact(&params));
            let result = fulfill_server_request(&method, &params);
            match &result {
                Ok(v) => println!("-> fulfilled {method}  {}", compact(v)),
                Err(e) => println!("-> declined {method}: {e}"),
            }
            result
        })
    })
}

/// Fulfill a server-initiated request with a safe canned response so the
/// server's flow completes. Pure so it is unit-testable without a transport.
///
/// - `sampling/createMessage`: a stub assistant message (no real model call).
/// - `elicitation/create`: decline, which is always a valid elicitation reply.
/// - `roots/list`: an empty root list.
fn fulfill_server_request(method: &str, _params: &Value) -> Result<Value, String> {
    match method {
        "sampling/createMessage" => Ok(json!({
            "role": "assistant",
            "content": {
                "type": "text",
                "text": "[mcptest inspect] sampling auto-fulfilled with a stub reply"
            },
            "model": "mcptest-inspect-stub",
            "stopReason": "endTurn",
        })),
        m if m.starts_with("elicitation/") => Ok(json!({ "action": "decline" })),
        "roots/list" => Ok(json!({ "roots": [] })),
        other => Err(format!(
            "mcptest inspect does not fulfill server request `{other}`"
        )),
    }
}

/// A parsed REPL command. The verb grammar is intentionally small and forgiving
/// so exploration stays fast.
#[derive(Debug, PartialEq)]
enum ReplCommand {
    Help,
    Tools,
    Resources,
    Prompts,
    Capabilities,
    Call { tool: String, args: Value },
    Read { uri: String },
    Prompt { name: String, args: Value },
    Notifications,
    Discover { path: String },
    Quit,
    Empty,
    Unknown(String),
    BadJson { verb: String },
}

/// Parse one input line into a [`ReplCommand`]. The first token is the verb;
/// the remainder is the verb's argument (a name/uri plus optional JSON object).
fn parse_command(line: &str) -> ReplCommand {
    let line = line.trim();
    if line.is_empty() {
        return ReplCommand::Empty;
    }
    let (verb, rest) = match line.split_once(char::is_whitespace) {
        Some((v, r)) => (v, r.trim()),
        None => (line, ""),
    };
    match verb {
        "help" | "?" => ReplCommand::Help,
        "tools" | "ls" => ReplCommand::Tools,
        "resources" => ReplCommand::Resources,
        "prompts" => ReplCommand::Prompts,
        "capabilities" | "caps" => ReplCommand::Capabilities,
        "notifications" | "notif" => ReplCommand::Notifications,
        "quit" | "exit" | "q" => ReplCommand::Quit,
        "call" => {
            let (tool, json_rest) = split_name_and_json(rest);
            if tool.is_empty() {
                return ReplCommand::Unknown("call requires a tool name".into());
            }
            match parse_json_args(json_rest) {
                Some(args) => ReplCommand::Call { tool, args },
                None => ReplCommand::BadJson {
                    verb: "call".into(),
                },
            }
        }
        "read" => {
            if rest.is_empty() {
                ReplCommand::Unknown("read requires a resource uri".into())
            } else {
                ReplCommand::Read {
                    uri: rest.to_string(),
                }
            }
        }
        "prompt" => {
            let (name, json_rest) = split_name_and_json(rest);
            if name.is_empty() {
                return ReplCommand::Unknown("prompt requires a prompt name".into());
            }
            match parse_json_args(json_rest) {
                Some(args) => ReplCommand::Prompt { name, args },
                None => ReplCommand::BadJson {
                    verb: "prompt".into(),
                },
            }
        }
        "discover" => ReplCommand::Discover {
            path: if rest.is_empty() {
                "tests.yaml".to_string()
            } else {
                rest.to_string()
            },
        },
        other => ReplCommand::Unknown(format!("unknown command `{other}` (try `help`)")),
    }
}

/// Split `rest` into a leading name token and the trailing JSON argument text.
fn split_name_and_json(rest: &str) -> (String, &str) {
    match rest.split_once(char::is_whitespace) {
        Some((name, json)) => (name.to_string(), json.trim()),
        None => (rest.to_string(), ""),
    }
}

/// Parse the optional JSON-object argument text. Empty means `{}`. Returns
/// `None` when the text is present but not a valid JSON object.
fn parse_json_args(text: &str) -> Option<Value> {
    if text.is_empty() {
        return Some(json!({}));
    }
    match serde_json::from_str::<Value>(text) {
        Ok(v) if v.is_object() => Some(v),
        _ => None,
    }
}

/// Whether the REPL loop should continue or quit after a command.
#[derive(Debug, PartialEq)]
enum Flow {
    Continue,
    Quit,
}

/// Execute one parsed command against the live `client`.
async fn dispatch(client: &Client, command: ReplCommand, log: &NotificationLog) -> Flow {
    match command {
        ReplCommand::Quit => return Flow::Quit,
        ReplCommand::Empty => {}
        ReplCommand::Help => print_help(),
        ReplCommand::Unknown(msg) => println!("{msg}"),
        ReplCommand::BadJson { verb } => {
            println!("{verb}: arguments must be a JSON object, e.g. `{verb} name {{\"k\": 1}}`");
        }
        ReplCommand::Tools => list_then(client, "tools/list", print_tools_pretty).await,
        ReplCommand::Resources => list_then(client, "resources/list", print_resources_pretty).await,
        ReplCommand::Prompts => list_then(client, "prompts/list", print_prompts_pretty).await,
        ReplCommand::Capabilities => reprint_capabilities(client).await,
        ReplCommand::Call { tool, args } => {
            request_and_print(
                client,
                "tools/call",
                json!({ "name": tool, "arguments": args }),
            )
            .await
        }
        ReplCommand::Read { uri } => {
            request_and_print(client, "resources/read", json!({ "uri": uri })).await
        }
        ReplCommand::Prompt { name, args } => {
            request_and_print(
                client,
                "prompts/get",
                json!({ "name": name, "arguments": args }),
            )
            .await
        }
        ReplCommand::Notifications => print_notification_log(log),
        ReplCommand::Discover { path } => discover_to_file(client, &path).await,
    }
    Flow::Continue
}

/// Run a `*/list` request and hand the result to a pretty-printer, mapping a
/// method-not-found error to an empty view so an unsupported namespace reads
/// cleanly rather than as a failure.
async fn list_then(client: &Client, method: &str, render: fn(&Value)) {
    match client.request(method, json!({})).await {
        Ok(v) => render(&v),
        Err(ProtocolError::ServerError { code: -32601, .. }) => {
            println!("(server does not support {method})");
        }
        Err(e) => println!("{method} failed: {e}"),
    }
}

/// Issue one request and print the JSON result (or the error).
async fn request_and_print(client: &Client, method: &str, params: Value) {
    match client.request(method, params).await {
        Ok(v) => match serde_json::to_string_pretty(&v) {
            Ok(s) => println!("{s}"),
            Err(_) => println!("{v}"),
        },
        Err(e) => println!("{method} failed: {e}"),
    }
}

/// Re-issue `initialize` to surface the current capability block. Cheap and
/// keeps the REPL stateless about the handshake result.
async fn reprint_capabilities(client: &Client) {
    match client.initialize(ProtocolVersion::current()).await {
        Ok(o) => print_capabilities(
            o.server.name.as_deref(),
            o.server.version.as_deref(),
            o.negotiated_version.as_wire(),
            &o.server.capabilities,
        ),
        Err(e) => println!("capabilities failed: {e}"),
    }
}

/// Print the notifications seen so far this session.
fn print_notification_log(log: &NotificationLog) {
    let entries = log.lock().map(|l| l.clone()).unwrap_or_default();
    if entries.is_empty() {
        println!("(no notifications received yet)");
        return;
    }
    for (method, params) in entries {
        println!("{method}  {}", compact(&params));
    }
}

/// Probe the three list namespaces and scaffold a starter suite to `path`.
async fn discover_to_file(client: &Client, path: &str) {
    let surface = DiscoveredSurface {
        tools: list_array(client, "tools/list", "tools").await,
        resources: list_array(client, "resources/list", "resources").await,
        prompts: list_array(client, "prompts/list", "prompts").await,
    };
    let body = scaffold_tests_yaml("inspected", "stdio-or-url", &surface);
    match std::fs::write(path, body) {
        Ok(()) => println!("wrote {path} ({} tools)", surface.tools.len()),
        Err(e) => println!("could not write {path}: {e}"),
    }
}

/// Fetch one list namespace as a vector, treating any error (including a
/// missing namespace) as empty so the scaffold still emits the others.
async fn list_array(client: &Client, method: &str, key: &str) -> Vec<Value> {
    client
        .request(method, json!({}))
        .await
        .ok()
        .and_then(|v| v.get(key).and_then(Value::as_array).cloned())
        .unwrap_or_default()
}

/// One-line JSON for log output.
fn compact(v: &Value) -> String {
    serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
}

fn print_help() {
    println!(
        "commands:\n\
         \x20 tools | ls            list tools\n\
         \x20 call <tool> [json]    call a tool with a JSON-object argument\n\
         \x20 resources             list resources\n\
         \x20 read <uri>            read a resource\n\
         \x20 prompts               list prompts\n\
         \x20 prompt <name> [json]  get a prompt\n\
         \x20 capabilities | caps   show the server capability block\n\
         \x20 notifications | notif show notifications seen this session\n\
         \x20 discover [path]       scaffold a tests.yaml from the live catalog\n\
         \x20 help | ?              this help\n\
         \x20 quit | exit | q       leave the session\n\
         \n\
         server-initiated sampling, elicitation, and roots requests are\n\
         fulfilled automatically and printed as they arrive."
    );
}

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

    #[test]
    fn parses_bare_verbs() {
        assert_eq!(parse_command("tools"), ReplCommand::Tools);
        assert_eq!(parse_command("ls"), ReplCommand::Tools);
        assert_eq!(parse_command("caps"), ReplCommand::Capabilities);
        assert_eq!(parse_command("  quit "), ReplCommand::Quit);
        assert_eq!(parse_command(""), ReplCommand::Empty);
        assert_eq!(parse_command("notif"), ReplCommand::Notifications);
    }

    #[test]
    fn parses_call_with_and_without_args() {
        assert_eq!(
            parse_command("call get_weather"),
            ReplCommand::Call {
                tool: "get_weather".into(),
                args: json!({}),
            }
        );
        assert_eq!(
            parse_command(r#"call get_weather {"city": "Denver"}"#),
            ReplCommand::Call {
                tool: "get_weather".into(),
                args: json!({ "city": "Denver" }),
            }
        );
    }

    #[test]
    fn rejects_non_object_call_args() {
        assert_eq!(
            parse_command("call x [1,2,3]"),
            ReplCommand::BadJson {
                verb: "call".into()
            }
        );
        assert_eq!(
            parse_command("call x not-json"),
            ReplCommand::BadJson {
                verb: "call".into()
            }
        );
    }

    #[test]
    fn parses_read_and_prompt_and_discover() {
        assert_eq!(
            parse_command("read file:///etc/hosts"),
            ReplCommand::Read {
                uri: "file:///etc/hosts".into()
            }
        );
        assert_eq!(
            parse_command("prompt greet"),
            ReplCommand::Prompt {
                name: "greet".into(),
                args: json!({}),
            }
        );
        assert_eq!(
            parse_command("discover"),
            ReplCommand::Discover {
                path: "tests.yaml".into()
            }
        );
        assert_eq!(
            parse_command("discover out/suite.yml"),
            ReplCommand::Discover {
                path: "out/suite.yml".into()
            }
        );
    }

    #[test]
    fn unknown_verb_is_reported_not_panicked() {
        assert!(matches!(
            parse_command("frobnicate"),
            ReplCommand::Unknown(_)
        ));
    }

    #[test]
    fn sampling_is_fulfilled_with_a_stub_message() {
        let out = fulfill_server_request("sampling/createMessage", &json!({})).unwrap();
        assert_eq!(out["role"], "assistant");
        assert_eq!(out["content"]["type"], "text");
        assert_eq!(out["stopReason"], "endTurn");
    }

    #[test]
    fn elicitation_is_declined() {
        let out = fulfill_server_request("elicitation/create", &json!({})).unwrap();
        assert_eq!(out["action"], "decline");
    }

    #[test]
    fn roots_list_returns_empty() {
        let out = fulfill_server_request("roots/list", &json!({})).unwrap();
        assert!(out["roots"].as_array().unwrap().is_empty());
    }

    #[test]
    fn unknown_server_request_is_declined_with_an_error() {
        assert!(fulfill_server_request("custom/thing", &json!({})).is_err());
    }

    #[test]
    fn advertised_capabilities_cover_the_fulfilled_features() {
        let caps = client_capabilities().capabilities;
        assert!(caps.get("sampling").is_some());
        assert!(caps.get("elicitation").is_some());
        assert!(caps.get("roots").is_some());
    }
}
