//! Fetcher and parser for `.well-known/mcp.json`.
//!
//! Why a blocking fetcher? The discovery surface is a one-shot helper used
//! by the `mcptest discover` subcommand. Keeping it synchronous matches the
//! rest of `mcptest-core` (transport-free, no tokio dependency in tests)
//! and lets the unit tests spin up a `wiremock` server with a local tokio
//! runtime without making the public API async.

use std::time::Duration;

use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::network::{classify_reqwest_error, Layer, NetworkDiagnostic};

/// Per-request timeout for the discovery fetch.
///
/// Ten seconds is generous for a single small JSON document. The runner can
/// adjust later if real-world endpoints push back on this floor.
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);

/// MCP discovery document parsed from `.well-known/mcp.json`.
///
/// Fields mirror the draft MCP discovery spec. `deny_unknown_fields` keeps
/// the parser strict so a server typo (`endpiont` instead of `endpoint`)
/// fails loudly during discovery rather than silently dropping a field.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct McpDiscovery {
    /// Human-readable server name shown in CLI output.
    pub name: Option<String>,
    /// One-line description of the server.
    pub description: Option<String>,
    /// Absolute URL of the MCP endpoint the client should connect to.
    pub endpoint: String,
    /// Transports the server supports, in priority order (for example
    /// `["streamable-http", "sse"]`).
    pub transports: Vec<String>,
    /// Optional authentication flow advertised by the server.
    pub auth: Option<DiscoveryAuth>,
    /// Optional tool catalog hint. Real tool discovery still goes through
    /// `tools/list` over MCP; this is purely a preview.
    pub tools: Option<Vec<DiscoveryTool>>,
}

/// Authentication flow advertised in a discovery document.
///
/// Tag-driven enum so the JSON form is `{"type": "oauth2", ...}` which
/// matches the MCP draft. Fields specific to OAuth2 live on the OAuth2
/// variant; the `none` and `bearer` variants are unit-shaped because the
/// runner needs no extra knobs from the discovery side.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum DiscoveryAuth {
    /// Server accepts unauthenticated requests.
    None,
    /// Server expects a static bearer token (the runner reads it from env
    /// or a token cache, not from the discovery document).
    Bearer,
    /// Server advertises an OAuth 2.1 authorization-code flow.
    #[serde(rename = "oauth2")]
    OAuth2 {
        /// Authorization endpoint the client redirects the user to.
        authorization_url: String,
        /// Token endpoint the client exchanges the auth code at.
        token_url: String,
        /// Scopes the server expects on the access token.
        scopes: Vec<String>,
        /// PKCE method, expected to be `"S256"` per the MCP spec.
        pkce: Option<String>,
    },
}

/// Tool catalog hint advertised in a discovery document.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiscoveryTool {
    /// Tool name as it appears in MCP `tools/list`.
    pub name: String,
    /// One-line description of the tool.
    pub description: Option<String>,
}

/// Discovery-specific error type.
///
/// Wraps a [`NetworkDiagnostic`] for the network-failure case so the runner
/// can render the same `[LAYER] url: message` shape it uses for runtime
/// failures.
#[derive(Debug, Error)]
pub enum DiscoveryError {
    /// `base_url` did not parse as an absolute URL.
    #[error("invalid base URL: {0}")]
    InvalidUrl(String),
    /// Network fetch failed before a response body was available.
    #[error("fetch failed: {0}")]
    Fetch(NetworkDiagnostic),
    /// Server returned a response that could not be parsed as a discovery
    /// document.
    #[error("document at {url} is not valid: {reason}")]
    Parse {
        /// The well-known URL the runner fetched.
        url: String,
        /// Underlying parser message.
        reason: String,
    },
    /// Server returned a non-2xx status the runner cannot recover from.
    #[error("HTTP {status} from {url}")]
    HttpStatus {
        /// The well-known URL the runner fetched.
        url: String,
        /// HTTP status code returned.
        status: u16,
    },
}

impl DiscoveryError {
    /// Returns the [`NetworkDiagnostic`] if this is a network-layer failure.
    /// Lets the reporter render the layer label uniformly.
    pub fn diagnostic(&self) -> Option<&NetworkDiagnostic> {
        match self {
            DiscoveryError::Fetch(d) => Some(d),
            _ => None,
        }
    }
}

/// Fetch and parse the document at `<base_url>/.well-known/mcp.json`.
///
/// Errors are categorized via the [`Layer`] enum so failures surface in the
/// same shape as runtime errors. See `docs/discovery.md` for a worked
/// example.
pub fn fetch(base_url: &str) -> Result<McpDiscovery, DiscoveryError> {
    let well_known = well_known_url(base_url)?;
    let client = reqwest::blocking::Client::builder()
        .timeout(FETCH_TIMEOUT)
        .build()
        .map_err(|err| {
            DiscoveryError::Fetch(
                NetworkDiagnostic::new(
                    classify_reqwest_error(&err),
                    well_known.clone(),
                    format!("client build failed: {err}"),
                )
                .with_suggestion("file an issue, the reqwest builder should not fail"),
            )
        })?;

    let response = client.get(&well_known).send().map_err(|err| {
        let layer = classify_reqwest_error(&err);
        let suggestion = suggestion_for(layer);
        let diag = NetworkDiagnostic::new(layer, well_known.clone(), err.to_string());
        let diag = match suggestion {
            Some(hint) => diag.with_suggestion(hint),
            None => diag,
        };
        DiscoveryError::Fetch(diag)
    })?;

    let status = response.status();
    if !status.is_success() {
        return Err(DiscoveryError::HttpStatus {
            url: well_known,
            status: status.as_u16(),
        });
    }

    let body = response.text().map_err(|err| {
        DiscoveryError::Fetch(NetworkDiagnostic::new(
            Layer::HttpTransport,
            well_known.clone(),
            format!("could not read response body: {err}"),
        ))
    })?;

    serde_json::from_str::<McpDiscovery>(&body).map_err(|err| DiscoveryError::Parse {
        url: well_known,
        reason: err.to_string(),
    })
}

/// Render an [`McpDiscovery`] as the `server:` block of an `mcptest` YAML
/// config. The runner can merge this output into `mcptest.local.yml`.
pub fn to_server_yaml(d: &McpDiscovery) -> String {
    let mut out = String::new();
    out.push_str("server:\n");
    out.push_str(&format!("  url: {}\n", d.endpoint));
    let transport = d
        .transports
        .first()
        .cloned()
        .unwrap_or_else(|| "streamable-http".to_string());
    out.push_str(&format!("  transport: {transport}\n"));
    if let Some(name) = &d.name {
        out.push_str(&format!("  name: {name}\n"));
    }
    if let Some(auth) = &d.auth {
        render_auth(&mut out, auth);
    }
    out
}

fn render_auth(out: &mut String, auth: &DiscoveryAuth) {
    out.push_str("  auth:\n");
    match auth {
        DiscoveryAuth::None => {
            out.push_str("    type: none\n");
        }
        DiscoveryAuth::Bearer => {
            out.push_str("    type: bearer\n");
        }
        DiscoveryAuth::OAuth2 {
            authorization_url,
            token_url,
            scopes,
            pkce,
        } => {
            out.push_str("    type: oauth2\n");
            out.push_str(&format!("    authorization_url: {authorization_url}\n"));
            out.push_str(&format!("    token_url: {token_url}\n"));
            out.push_str("    scopes:\n");
            for scope in scopes {
                out.push_str(&format!("      - {scope}\n"));
            }
            if let Some(pkce) = pkce {
                out.push_str(&format!("    pkce: {pkce}\n"));
            }
        }
    }
}

fn well_known_url(base_url: &str) -> Result<String, DiscoveryError> {
    let parsed = url::Url::parse(base_url)
        .map_err(|err| DiscoveryError::InvalidUrl(format!("{base_url}: {err}")))?;
    if parsed.cannot_be_a_base() {
        return Err(DiscoveryError::InvalidUrl(format!(
            "{base_url}: not an absolute URL"
        )));
    }
    let trimmed = base_url.trim_end_matches('/');
    Ok(format!("{trimmed}/.well-known/mcp.json"))
}

fn suggestion_for(layer: Layer) -> Option<&'static str> {
    match layer {
        Layer::Dns => Some("check the hostname or your DNS resolver"),
        Layer::Tcp => Some("verify the server is running and the port is reachable"),
        Layer::Tls => {
            Some("inspect the certificate chain or set transport.tls.verify=false locally")
        }
        Layer::Authentication => {
            Some("supply credentials with --bearer-token or run `mcptest login`")
        }
        Layer::HttpTransport => Some("retry, then inspect the server logs"),
        Layer::McpInitialize | Layer::Readiness => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use wiremock::matchers::{method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn rt() -> tokio::runtime::Runtime {
        tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap()
    }

    fn sample_doc_json() -> serde_json::Value {
        serde_json::json!({
            "name": "Example MCP",
            "description": "A sample server",
            "endpoint": "https://mcp.example.com/v1",
            "transports": ["streamable-http", "sse"],
            "auth": { "type": "none" },
            "tools": [
                { "name": "echo", "description": "echoes input" }
            ]
        })
    }

    #[test]
    fn fetch_parses_happy_path_document() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("GET"))
                .and(path("/.well-known/mcp.json"))
                .respond_with(ResponseTemplate::new(200).set_body_json(sample_doc_json()))
                .mount(&server)
                .await;
            let uri = server.uri();
            (uri, server)
        });

        let doc = fetch(&uri).expect("fetch should succeed");
        assert_eq!(doc.name.as_deref(), Some("Example MCP"));
        assert_eq!(doc.endpoint, "https://mcp.example.com/v1");
        assert_eq!(doc.transports, vec!["streamable-http", "sse"]);
        assert_eq!(doc.auth, Some(DiscoveryAuth::None));
        assert_eq!(
            doc.tools.as_ref().unwrap()[0],
            DiscoveryTool {
                name: "echo".into(),
                description: Some("echoes input".into())
            }
        );
    }

    #[test]
    fn fetch_returns_http_status_on_404() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("GET"))
                .and(path("/.well-known/mcp.json"))
                .respond_with(ResponseTemplate::new(404))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let err = fetch(&uri).unwrap_err();
        match err {
            DiscoveryError::HttpStatus { status, .. } => assert_eq!(status, 404),
            other => panic!("expected HttpStatus, got {other:?}"),
        }
    }

    #[test]
    fn fetch_returns_parse_error_on_bad_json() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("GET"))
                .and(path("/.well-known/mcp.json"))
                .respond_with(ResponseTemplate::new(200).set_body_string("{ not json"))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let err = fetch(&uri).unwrap_err();
        match err {
            DiscoveryError::Parse { reason, .. } => assert!(!reason.is_empty()),
            other => panic!("expected Parse, got {other:?}"),
        }
    }

    #[test]
    fn fetch_returns_fetch_error_on_connection_refused() {
        // Bind a TCP port, drop the listener so the port is free, and then
        // try to hit the same port. The connect attempt must fail.
        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 err = fetch(&url).unwrap_err();
        match err {
            DiscoveryError::Fetch(diag) => {
                assert!(
                    matches!(diag.layer, Layer::Tcp | Layer::Dns | Layer::HttpTransport),
                    "unexpected layer: {:?}",
                    diag.layer
                );
                assert!(diag.url.ends_with("/.well-known/mcp.json"));
            }
            other => panic!("expected Fetch, got {other:?}"),
        }
    }

    #[test]
    fn fetch_rejects_invalid_url() {
        let err = fetch("not a url").unwrap_err();
        assert!(matches!(err, DiscoveryError::InvalidUrl(_)));
    }

    #[test]
    fn to_server_yaml_emits_minimal_block_for_no_auth() {
        let doc = McpDiscovery {
            name: None,
            description: None,
            endpoint: "https://mcp.example.com/v1".into(),
            transports: vec!["streamable-http".into()],
            auth: Some(DiscoveryAuth::None),
            tools: None,
        };
        let yaml = to_server_yaml(&doc);
        assert_eq!(
            yaml,
            "server:\n  url: https://mcp.example.com/v1\n  transport: streamable-http\n  auth:\n    type: none\n"
        );
    }

    #[test]
    fn to_server_yaml_emits_full_oauth2_block() {
        let doc = McpDiscovery {
            name: Some("Example".into()),
            description: None,
            endpoint: "https://mcp.example.com/v1".into(),
            transports: vec!["streamable-http".into()],
            auth: Some(DiscoveryAuth::OAuth2 {
                authorization_url: "https://auth.example.com/authorize".into(),
                token_url: "https://auth.example.com/token".into(),
                scopes: vec!["mcp.read".into(), "mcp.write".into()],
                pkce: Some("S256".into()),
            }),
            tools: None,
        };
        let yaml = to_server_yaml(&doc);
        let expected = "server:\n  url: https://mcp.example.com/v1\n  transport: streamable-http\n  name: Example\n  auth:\n    type: oauth2\n    authorization_url: https://auth.example.com/authorize\n    token_url: https://auth.example.com/token\n    scopes:\n      - mcp.read\n      - mcp.write\n    pkce: S256\n";
        assert_eq!(yaml, expected);
    }

    #[test]
    fn well_known_url_strips_trailing_slash() {
        let one = well_known_url("https://mcp.example.com").unwrap();
        let two = well_known_url("https://mcp.example.com/").unwrap();
        assert_eq!(one, two);
        assert_eq!(one, "https://mcp.example.com/.well-known/mcp.json");
    }

    #[test]
    fn deny_unknown_fields_rejects_typo() {
        let bad = "{\"endpoint\":\"x\",\"transports\":[],\"endpoiint\":\"oops\"}";
        let err = serde_json::from_str::<McpDiscovery>(bad).unwrap_err();
        assert!(err.to_string().contains("endpoiint"));
    }
}
