//! Concrete `scheme://` secret-resolver backends for the run path.
//!
//! The spine (WOR-1449/1453/1454) ships the scheme dispatch, the run-path
//! interpolation hook, and the redaction of resolved plaintext. These modules
//! are the backends that turn a reference into plaintext over one blocking
//! HTTP call: HashiCorp Vault (`vault://`), AWS Secrets Manager (`awssm://`),
//! and GCP Secret Manager (`gcpsm://`).
//!
//! They live in the binary crate (not `mcptest-config`) because they do
//! network I/O: the binary already depends on `reqwest` (blocking feature) and
//! on `mcptest_core::auth` for the AWS SigV4 signer, so the config crate stays
//! HTTP-free. Resolution runs synchronously during interpolation, before the
//! run's tokio runtime is built, so a blocking client is the right tool (same
//! pattern as `conformance_refresh`).
//!
//! No Cargo feature gates these: the implementation reuses dependencies the
//! binary already links (`reqwest`, `mcptest_core::auth` for SigV4, `base64`,
//! `serde_json`), so there is no heavy SDK to keep out of the default build,
//! and a feature flag that gates nothing would only hide the tests from the
//! default `cargo nextest` gate (which does not pass `--all-features`).

mod awssm;
mod gcpsm;
mod vault;

use std::time::Duration;

use mcptest_config::secrets::{ResolverError, SecretRegistry};

/// Register every concrete secret backend on a run-path registry.
///
/// Called from `build_secret_registry`; each backend keys on its own scheme,
/// so the order is irrelevant and a later epic can add one with a single line.
pub(crate) fn register_all(registry: &mut SecretRegistry) {
    registry.register(Box::new(vault::VaultResolver::new()));
    registry.register(Box::new(awssm::AwsSecretsManagerResolver::new()));
    registry.register(Box::new(gcpsm::GcpSecretManagerResolver::new()));
}

/// Build the blocking HTTP client the backends share. A 30s timeout keeps a
/// hung secret store from stalling the whole run; a transport error fails the
/// resolution closed rather than substituting an empty credential.
fn http_client() -> Result<reqwest::blocking::Client, ResolverError> {
    reqwest::blocking::Client::builder()
        .user_agent(concat!("mcptest/", env!("CARGO_PKG_VERSION")))
        .timeout(Duration::from_secs(30))
        .build()
        .map_err(|e| ResolverError(format!("could not build HTTP client: {e}")))
}

/// The `?key=<field>&version=<v>` suffix the three schemes share.
///
/// `body` is everything before the `?` (the backend-specific path or id);
/// `key` selects a field from a JSON secret; `version` pins a version. Parsing
/// is intentionally literal (no percent-decoding): keys and versions are plain
/// identifiers, and a backend that needs richer parsing owns it.
struct RefQuery {
    body: String,
    key: Option<String>,
    version: Option<String>,
}

/// Split a whole reference into its body and `?key=`/`&version=` query.
///
/// The leading `scheme://` is stripped first so each backend works with the
/// path/id it cares about. Unknown query keys are ignored so a future param
/// does not break an older binary.
fn split_query(raw: &str, scheme: &str) -> RefQuery {
    let prefix = format!("{scheme}://");
    let after = raw.strip_prefix(&prefix).unwrap_or(raw);
    let (body, query) = match after.split_once('?') {
        Some((b, q)) => (b, Some(q)),
        None => (after, None),
    };
    let mut key = None;
    let mut version = None;
    if let Some(query) = query {
        for pair in query.split('&') {
            match pair.split_once('=') {
                Some(("key", v)) => key = Some(v.to_string()),
                Some(("version", v)) => version = Some(v.to_string()),
                _ => {}
            }
        }
    }
    RefQuery {
        body: body.to_string(),
        key,
        version,
    }
}

/// Extract a string field from a JSON secret payload.
///
/// The common "secret stored as a JSON map" case: `?key=api_key` reads the
/// `api_key` string out of `{"api_key":"...","other":"..."}`. A non-JSON
/// payload, a missing field, or a non-string field all fail closed so a
/// misconfigured key never silently yields the wrong value. The error names
/// the field (author wrote it) but never the payload.
fn json_field(payload: &str, field: &str) -> Result<String, ResolverError> {
    let value: serde_json::Value = serde_json::from_str(payload)
        .map_err(|_| ResolverError(format!("secret is not JSON, cannot extract key `{field}`")))?;
    match value.get(field).and_then(serde_json::Value::as_str) {
        Some(found) => Ok(found.to_string()),
        None => Err(ResolverError(format!(
            "key `{field}` is not a string field of the JSON secret"
        ))),
    }
}

/// Read a required environment variable, failing closed when unset or blank.
///
/// Credentials and endpoints come from the environment (the CI-first path); a
/// missing one aborts resolution rather than sending no credential.
fn env_required(name: &str) -> Result<String, ResolverError> {
    match std::env::var(name) {
        Ok(value) if !value.trim().is_empty() => Ok(value),
        _ => Err(ResolverError(format!(
            "environment variable `{name}` is not set"
        ))),
    }
}

/// Read an optional environment variable, treating blank as absent.
fn env_optional(name: &str) -> Option<String> {
    match std::env::var(name) {
        Ok(value) if !value.trim().is_empty() => Some(value),
        _ => None,
    }
}

#[cfg(test)]
pub(super) mod testutil {
    //! Shared test scaffolding: a process-env lock plus a current-thread
    //! runtime for standing up a wiremock server. `cargo test` runs `#[test]`
    //! functions as threads in one process and process env is global, so every
    //! env-touching backend test serializes through one lock and restores the
    //! vars it touched on drop.
    use std::sync::{Mutex, MutexGuard};

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    /// Hold the global env lock, clear a set of vars for a clean slate, and
    /// restore their original values on drop.
    pub(crate) struct EnvScope {
        _guard: MutexGuard<'static, ()>,
        saved: Vec<(String, Option<String>)>,
    }

    impl EnvScope {
        /// Lock, snapshot, and clear every var in `vars` so a value leaked
        /// from the real environment cannot bleed into the test.
        pub(crate) fn new(vars: &[&str]) -> Self {
            let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
            let saved = vars
                .iter()
                .map(|v| ((*v).to_string(), std::env::var(v).ok()))
                .collect();
            for v in vars {
                std::env::remove_var(v);
            }
            Self {
                _guard: guard,
                saved,
            }
        }

        /// Set a var for the duration of the scope.
        pub(crate) fn set(&self, key: &str, value: &str) {
            std::env::set_var(key, value);
        }
    }

    impl Drop for EnvScope {
        fn drop(&mut self) {
            for (key, value) in &self.saved {
                match value {
                    Some(value) => std::env::set_var(key, value),
                    None => std::env::remove_var(key),
                }
            }
        }
    }

    /// A current-thread runtime used only to start a wiremock server. The
    /// blocking resolver under test runs on the test thread, outside this
    /// runtime, so its blocking client never sits inside an async context.
    pub(crate) fn rt() -> tokio::runtime::Runtime {
        tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .expect("test runtime builds")
    }
}

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

    #[test]
    fn split_query_separates_body_key_and_version() {
        let parsed = split_query("vault://secret/data/openai?key=api_key&version=3", "vault");
        assert_eq!(parsed.body, "secret/data/openai");
        assert_eq!(parsed.key.as_deref(), Some("api_key"));
        assert_eq!(parsed.version.as_deref(), Some("3"));
    }

    #[test]
    fn split_query_without_a_query_keeps_the_whole_body() {
        let parsed = split_query("gcpsm://openai", "gcpsm");
        assert_eq!(parsed.body, "openai");
        assert!(parsed.key.is_none());
        assert!(parsed.version.is_none());
    }

    #[test]
    fn json_field_reads_a_string_member() {
        let value = json_field(r#"{"api_key":"sk-live-xyz","note":"n"}"#, "api_key").unwrap();
        assert_eq!(value, "sk-live-xyz");
    }

    #[test]
    fn json_field_fails_closed_on_missing_or_non_json() {
        assert!(json_field(r#"{"a":"b"}"#, "missing").is_err());
        assert!(json_field("not json", "a").is_err());
        // A non-string member is rejected rather than stringified.
        assert!(json_field(r#"{"a":123}"#, "a").is_err());
    }
}
