//! MCP server configuration types.

use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;
use std::time::Duration;

use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as SerdeError;

use crate::RequirementSource;

/// Effective MCP environment id when config omits `environment_id`.
pub const DEFAULT_MCP_SERVER_ENVIRONMENT_ID: &str = "local";

#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AppToolApproval {
    #[default]
    Auto,
    Prompt,
    Approve,
}

/// Human-readable reason a configured MCP server was disabled after requirements
/// were applied.
///
/// `Display` is intentionally implemented for CLI/TUI status output; avoid
/// relying on `Debug` because enum variant syntax is not part of the user-facing
/// message contract.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpServerDisabledReason {
    /// The server is disabled, but there is no more specific user-facing reason.
    Unknown,
    /// The server was disabled by config requirements from the given source.
    Requirements { source: RequirementSource },
}

impl fmt::Display for McpServerDisabledReason {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            McpServerDisabledReason::Unknown => write!(f, "unknown"),
            McpServerDisabledReason::Requirements { source } => {
                write!(f, "requirements ({source})")
            }
        }
    }
}

/// Per-tool approval settings for a single MCP server tool.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct McpServerToolConfig {
    /// Approval mode for this tool.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approval_mode: Option<AppToolApproval>,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(untagged, deny_unknown_fields)]
pub enum McpServerEnvVar {
    Name(String),
    Config {
        name: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        source: Option<String>,
    },
}

impl McpServerEnvVar {
    pub fn name(&self) -> &str {
        match self {
            McpServerEnvVar::Name(name) => name,
            McpServerEnvVar::Config { name, .. } => name,
        }
    }

    pub fn source(&self) -> Option<&str> {
        match self {
            McpServerEnvVar::Name(_) => None,
            McpServerEnvVar::Config { source, .. } => source.as_deref(),
        }
    }

    pub fn is_remote_source(&self) -> bool {
        self.source() == Some("remote")
    }

    pub fn validate_source(&self) -> Result<(), String> {
        match self.source() {
            None | Some("local") | Some("remote") => Ok(()),
            Some(source) => Err(format!(
                "unsupported env_vars source `{source}`; expected `local` or `remote`"
            )),
        }
    }
}

impl From<String> for McpServerEnvVar {
    fn from(value: String) -> Self {
        Self::Name(value)
    }
}

impl From<&str> for McpServerEnvVar {
    fn from(value: &str) -> Self {
        Self::Name(value.to_string())
    }
}

impl AsRef<str> for McpServerEnvVar {
    fn as_ref(&self) -> &str {
        self.name()
    }
}

/// OAuth client settings used when Codex launches an MCP OAuth flow.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct McpServerOAuthConfig {
    /// Explicit OAuth client identifier to present during authorization and token exchange.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub client_id: Option<String>,
}

#[derive(Serialize, Debug, Clone, PartialEq)]
pub struct McpServerConfig {
    #[serde(flatten)]
    pub transport: McpServerTransportConfig,

    /// Effective environment id for where Codex should start this MCP server.
    pub environment_id: String,

    /// When `false`, Codex skips initializing this MCP server.
    #[serde(default = "default_enabled")]
    pub enabled: bool,

    /// When `true`, `codex exec` exits with an error if this MCP server fails to initialize.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub required: bool,

    /// When `true`, every tool from this server is advertised as safe for parallel tool calls.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub supports_parallel_tool_calls: bool,

    /// Reason this server was disabled after applying requirements.
    #[serde(skip)]
    pub disabled_reason: Option<McpServerDisabledReason>,

    /// Startup timeout in seconds for initializing MCP server & initially listing tools.
    #[serde(
        default,
        with = "option_duration_secs",
        skip_serializing_if = "Option::is_none"
    )]
    pub startup_timeout_sec: Option<Duration>,

    /// Default timeout for MCP tool calls initiated via this server.
    #[serde(default, with = "option_duration_secs")]
    pub tool_timeout_sec: Option<Duration>,

    /// Approval mode for tools in this server unless a tool override exists.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_tools_approval_mode: Option<AppToolApproval>,

    /// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub enabled_tools: Option<Vec<String>>,

    /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub disabled_tools: Option<Vec<String>>,

    /// Optional OAuth scopes to request during MCP login.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scopes: Option<Vec<String>>,

    /// Optional OAuth client settings for MCP login.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub oauth: Option<McpServerOAuthConfig>,

    /// Optional OAuth resource parameter to include during MCP login (RFC 8707).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub oauth_resource: Option<String>,

    /// Per-tool approval settings keyed by tool name.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub tools: HashMap<String, McpServerToolConfig>,
}

impl McpServerConfig {
    pub fn is_local_environment(&self) -> bool {
        self.environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID
    }

    pub fn oauth_client_id(&self) -> Option<&str> {
        self.oauth
            .as_ref()
            .and_then(|oauth| oauth.client_id.as_deref())
    }
}

/// Raw MCP config shape used for deserialization and supported-field JSON
/// Schema generation.
///
/// Fields that are accepted only to produce targeted validation errors should
/// be skipped in the generated schema.
///
/// Keep `TryFrom<RawMcpServerConfig> for McpServerConfig` exhaustively
/// destructuring this struct so new TOML fields cannot be added here without
/// updating the validation/mapping logic that produces [`McpServerConfig`].
#[derive(Deserialize, Clone, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RawMcpServerConfig {
    // stdio
    pub command: Option<String>,
    #[serde(default)]
    pub args: Option<Vec<String>>,
    #[serde(default)]
    pub env: Option<HashMap<String, String>>,
    #[serde(default)]
    pub env_vars: Option<Vec<McpServerEnvVar>>,
    #[serde(default)]
    pub cwd: Option<PathBuf>,
    pub http_headers: Option<HashMap<String, String>>,
    #[serde(default)]
    pub env_http_headers: Option<HashMap<String, String>>,

    // streamable_http
    pub url: Option<String>,
    #[schemars(skip)]
    pub bearer_token: Option<String>,
    pub bearer_token_env_var: Option<String>,

    // shared
    #[serde(default)]
    pub environment_id: Option<String>,
    #[serde(default)]
    pub startup_timeout_sec: Option<f64>,
    #[serde(default)]
    pub startup_timeout_ms: Option<u64>,
    #[serde(default, with = "option_duration_secs")]
    #[schemars(with = "Option<f64>")]
    pub tool_timeout_sec: Option<Duration>,
    #[serde(default)]
    pub enabled: Option<bool>,
    #[serde(default)]
    pub required: Option<bool>,
    #[serde(default)]
    pub supports_parallel_tool_calls: Option<bool>,
    #[serde(default)]
    pub default_tools_approval_mode: Option<AppToolApproval>,
    #[serde(default)]
    pub enabled_tools: Option<Vec<String>>,
    #[serde(default)]
    pub disabled_tools: Option<Vec<String>>,
    #[serde(default)]
    pub scopes: Option<Vec<String>>,
    #[serde(default)]
    pub oauth: Option<McpServerOAuthConfig>,
    #[serde(default)]
    pub oauth_resource: Option<String>,
    /// Legacy display-name field accepted for backward compatibility.
    #[serde(default, rename = "name")]
    pub _name: Option<String>,
    #[serde(default)]
    pub tools: Option<HashMap<String, McpServerToolConfig>>,
}

impl TryFrom<RawMcpServerConfig> for McpServerConfig {
    type Error = String;

    fn try_from(raw: RawMcpServerConfig) -> Result<Self, Self::Error> {
        let RawMcpServerConfig {
            command,
            args,
            env,
            env_vars,
            cwd,
            http_headers,
            env_http_headers,
            url,
            bearer_token,
            bearer_token_env_var,
            environment_id,
            startup_timeout_sec,
            startup_timeout_ms,
            tool_timeout_sec,
            enabled,
            required,
            supports_parallel_tool_calls,
            default_tools_approval_mode,
            enabled_tools,
            disabled_tools,
            scopes,
            oauth,
            oauth_resource,
            _name: _,
            tools,
        } = raw;

        let startup_timeout_sec = match (startup_timeout_sec, startup_timeout_ms) {
            (Some(sec), _) => {
                Some(Duration::try_from_secs_f64(sec).map_err(|err| err.to_string())?)
            }
            (None, Some(ms)) => Some(Duration::from_millis(ms)),
            (None, None) => None,
        };

        fn throw_if_set<T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), String> {
            if value.is_none() {
                return Ok(());
            }
            Err(format!("{field} is not supported for {transport}"))
        }

        let transport = if let Some(command) = command {
            throw_if_set("stdio", "url", url.as_ref())?;
            throw_if_set(
                "stdio",
                "bearer_token_env_var",
                bearer_token_env_var.as_ref(),
            )?;
            throw_if_set("stdio", "bearer_token", bearer_token.as_ref())?;
            throw_if_set("stdio", "http_headers", http_headers.as_ref())?;
            throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?;
            throw_if_set("stdio", "oauth", oauth.as_ref())?;
            throw_if_set("stdio", "oauth_resource", oauth_resource.as_ref())?;
            let env_vars = env_vars.unwrap_or_default();
            for env_var in &env_vars {
                env_var.validate_source()?;
            }
            McpServerTransportConfig::Stdio {
                command,
                args: args.unwrap_or_default(),
                env,
                env_vars,
                cwd,
            }
        } else if let Some(url) = url {
            throw_if_set("streamable_http", "args", args.as_ref())?;
            throw_if_set("streamable_http", "env", env.as_ref())?;
            throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?;
            throw_if_set("streamable_http", "cwd", cwd.as_ref())?;
            throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?;
            McpServerTransportConfig::StreamableHttp {
                url,
                bearer_token_env_var,
                http_headers,
                env_http_headers,
            }
        } else {
            return Err("invalid transport".to_string());
        };

        let environment_id =
            environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string());
        validate_remote_stdio_cwd(&transport, &environment_id)?;

        Ok(Self {
            transport,
            environment_id,
            startup_timeout_sec,
            tool_timeout_sec,
            enabled: enabled.unwrap_or_else(default_enabled),
            required: required.unwrap_or_default(),
            supports_parallel_tool_calls: supports_parallel_tool_calls.unwrap_or_default(),
            disabled_reason: None,
            default_tools_approval_mode,
            enabled_tools,
            disabled_tools,
            scopes,
            oauth,
            oauth_resource,
            tools: tools.unwrap_or_default(),
        })
    }
}

impl<'de> Deserialize<'de> for McpServerConfig {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        RawMcpServerConfig::deserialize(deserializer)?
            .try_into()
            .map_err(SerdeError::custom)
    }
}

const fn default_enabled() -> bool {
    true
}

fn validate_remote_stdio_cwd(
    transport: &McpServerTransportConfig,
    environment_id: &str,
) -> Result<(), String> {
    if environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID {
        return Ok(());
    }
    let McpServerTransportConfig::Stdio { cwd, .. } = transport else {
        return Ok(());
    };
    let Some(cwd) = cwd else {
        return Err(format!(
            "remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`"
        ));
    };
    if cwd.is_absolute() {
        return Ok(());
    }
    Err(format!(
        "remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`, got `{}`",
        cwd.display()
    ))
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")]
pub enum McpServerTransportConfig {
    /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio
    Stdio {
        command: String,
        #[serde(default)]
        args: Vec<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        env: Option<HashMap<String, String>>,
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        env_vars: Vec<McpServerEnvVar>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        cwd: Option<PathBuf>,
    },
    /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http
    StreamableHttp {
        url: String,
        /// Name of the environment variable to read for an HTTP bearer token.
        /// When set, requests will include the token via `Authorization: Bearer <token>`.
        /// The actual secret value must be provided via the environment.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        bearer_token_env_var: Option<String>,
        /// Additional HTTP headers to include in requests to this server.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        http_headers: Option<HashMap<String, String>>,
        /// HTTP headers where the value is sourced from an environment variable.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        env_http_headers: Option<HashMap<String, String>>,
    },
}

mod option_duration_secs {
    use serde::Deserialize;
    use serde::Deserializer;
    use serde::Serializer;
    use std::time::Duration;

    pub fn serialize<S>(value: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match value {
            Some(duration) => serializer.serialize_some(&duration.as_secs_f64()),
            None => serializer.serialize_none(),
        }
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
    where
        D: Deserializer<'de>,
    {
        let secs = Option::<f64>::deserialize(deserializer)?;
        secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom))
            .transpose()
    }
}

#[cfg(test)]
#[path = "mcp_types_tests.rs"]
mod tests;
