//! HashiCorp Vault resolver for `vault://` references (WOR-1450).
//!
//! Grammar: `vault://<path>[?key=<field>][&version=<n>]`, e.g.
//! `vault://secret/data/openai-prod?key=api_key`. `<path>` is the full KV API
//! path (KV v2 nests the secret under `data/`), `key` selects a field from the
//! JSON secret, and `version` pins a KV v2 version.
//!
//! Credentials are discovered from the environment in this order: a `VAULT_TOKEN`,
//! then AppRole (`VAULT_ROLE_ID` + `VAULT_SECRET_ID`), then Kubernetes
//! (`VAULT_K8S_ROLE` + the projected service-account token), then JWT/OIDC
//! (`VAULT_JWT_ROLE` + `VAULT_JWT`, the GitHub-OIDC-to-Vault CI path). The
//! login methods all return an `auth.client_token` the KV read then carries.

use serde_json::json;

use mcptest_config::secrets::{ResolverError, SecretRef, SecretResolver};

use super::{env_optional, env_required, http_client, split_query};

/// Resolver for the `vault://` scheme. Stateless: every field comes from the
/// environment per resolution so one registry instance serves the whole run.
pub(super) struct VaultResolver;

impl VaultResolver {
    pub(super) fn new() -> Self {
        Self
    }
}

impl SecretResolver for VaultResolver {
    fn scheme(&self) -> &str {
        "vault"
    }

    fn resolve(&self, reference: &SecretRef) -> Result<String, ResolverError> {
        let addr = env_required("VAULT_ADDR")?;
        let addr = addr.trim_end_matches('/');
        let client = http_client()?;
        let token = vault_token(addr, &client)?;
        let parsed = split_query(&reference.raw, "vault");

        let url = read_url(addr, &parsed.body, parsed.version.as_deref());
        let mut request = client.get(&url).header("X-Vault-Token", &token);
        if let Some(namespace) = env_optional("VAULT_NAMESPACE") {
            request = request.header("X-Vault-Namespace", namespace);
        }
        let response = request
            .send()
            .map_err(|e| ResolverError(format!("Vault request failed: {e}")))?;
        let status = response.status();
        let body = response
            .text()
            .map_err(|e| ResolverError(format!("Vault response read failed: {e}")))?;
        if !status.is_success() {
            return Err(ResolverError(format!(
                "Vault returned HTTP {} for `{}`",
                status.as_u16(),
                reference.raw
            )));
        }
        extract_secret(&body, parsed.key.as_deref())
    }
}

/// Build the KV read URL, pinning a version when one was requested.
fn read_url(addr: &str, body: &str, version: Option<&str>) -> String {
    match version {
        Some(version) => format!("{addr}/v1/{body}?version={version}"),
        None => format!("{addr}/v1/{body}"),
    }
}

/// Resolve a usable client token from whichever credential set is present.
fn vault_token(addr: &str, client: &reqwest::blocking::Client) -> Result<String, ResolverError> {
    if let Some(token) = env_optional("VAULT_TOKEN") {
        return Ok(token);
    }
    if let (Some(role_id), Some(secret_id)) = (
        env_optional("VAULT_ROLE_ID"),
        env_optional("VAULT_SECRET_ID"),
    ) {
        return login(
            addr,
            client,
            "approle",
            json!({ "role_id": role_id, "secret_id": secret_id }),
        );
    }
    if let Some(role) = env_optional("VAULT_K8S_ROLE") {
        let jwt_path = env_optional("VAULT_K8S_JWT_PATH")
            .unwrap_or_else(|| "/var/run/secrets/kubernetes.io/serviceaccount/token".to_string());
        let jwt = std::fs::read_to_string(&jwt_path).map_err(|e| {
            ResolverError(format!(
                "could not read Kubernetes service-account token at `{jwt_path}`: {e}"
            ))
        })?;
        return login(
            addr,
            client,
            "kubernetes",
            json!({ "role": role, "jwt": jwt.trim() }),
        );
    }
    if let (Some(role), Some(jwt)) = (env_optional("VAULT_JWT_ROLE"), env_optional("VAULT_JWT")) {
        return login(addr, client, "jwt", json!({ "role": role, "jwt": jwt }));
    }
    Err(ResolverError(
        "no Vault credentials: set VAULT_TOKEN, VAULT_ROLE_ID+VAULT_SECRET_ID, VAULT_K8S_ROLE, or VAULT_JWT_ROLE+VAULT_JWT"
            .to_string(),
    ))
}

/// POST a login to an auth method and pull `auth.client_token` from the reply.
fn login(
    addr: &str,
    client: &reqwest::blocking::Client,
    method: &str,
    body: serde_json::Value,
) -> Result<String, ResolverError> {
    let url = format!("{addr}/v1/auth/{method}/login");
    let response = client
        .post(&url)
        .json(&body)
        .send()
        .map_err(|e| ResolverError(format!("Vault {method} login request failed: {e}")))?;
    let status = response.status();
    let text = response
        .text()
        .map_err(|e| ResolverError(format!("Vault {method} login read failed: {e}")))?;
    if !status.is_success() {
        return Err(ResolverError(format!(
            "Vault {method} login returned HTTP {}",
            status.as_u16()
        )));
    }
    let json: serde_json::Value = serde_json::from_str(&text)
        .map_err(|_| ResolverError(format!("Vault {method} login response is not JSON")))?;
    json.pointer("/auth/client_token")
        .and_then(serde_json::Value::as_str)
        .map(str::to_string)
        .ok_or_else(|| {
            ResolverError(format!(
                "Vault {method} login response has no auth.client_token"
            ))
        })
}

/// Pull the requested field out of a KV read response.
///
/// KV v2 nests the secret map under `data.data`; KV v1 puts it under `data`.
/// With `?key=`, that field must be a string. Without one, a single-field
/// secret resolves unambiguously; a multi-field secret asks the author to pick
/// a field rather than guessing.
fn extract_secret(body: &str, key: Option<&str>) -> Result<String, ResolverError> {
    let json: serde_json::Value = serde_json::from_str(body)
        .map_err(|_| ResolverError("Vault response is not JSON".to_string()))?;
    let data = json
        .pointer("/data/data")
        .or_else(|| json.get("data"))
        .ok_or_else(|| ResolverError("Vault response has no `data` object".to_string()))?;
    let map = data
        .as_object()
        .ok_or_else(|| ResolverError("Vault `data` is not an object".to_string()))?;
    match key {
        Some(key) => map
            .get(key)
            .and_then(serde_json::Value::as_str)
            .map(str::to_string)
            .ok_or_else(|| {
                ResolverError(format!("field `{key}` is not a string in the Vault secret"))
            }),
        None if map.len() == 1 => map
            .values()
            .next()
            .and_then(serde_json::Value::as_str)
            .map(str::to_string)
            .ok_or_else(|| ResolverError("the sole Vault field is not a string".to_string())),
        None => Err(ResolverError(
            "the Vault secret has multiple fields, add ?key=<field>".to_string(),
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::super::testutil::{rt, EnvScope};
    use super::*;
    use mcptest_config::secrets::detect_reference;
    use wiremock::matchers::{body_json, header, method, path, query_param};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    /// Every Vault env var a test might set, cleared and restored together.
    const VAULT_VARS: &[&str] = &[
        "VAULT_ADDR",
        "VAULT_TOKEN",
        "VAULT_NAMESPACE",
        "VAULT_ROLE_ID",
        "VAULT_SECRET_ID",
        "VAULT_K8S_ROLE",
        "VAULT_K8S_JWT_PATH",
        "VAULT_JWT_ROLE",
        "VAULT_JWT",
    ];

    fn reference(raw: &str) -> SecretRef {
        detect_reference(raw).expect("a vault:// value is a reference")
    }

    #[test]
    fn token_auth_reads_a_kv_v2_field_with_a_version_pin() {
        let scope = EnvScope::new(VAULT_VARS);
        let runtime = rt();
        let (uri, _server) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("GET"))
                .and(path("/v1/secret/data/openai-prod"))
                .and(query_param("version", "3"))
                .and(header("x-vault-token", "s.dev-token"))
                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                    "data": { "data": { "api_key": "sk-live-vault" }, "metadata": {} }
                })))
                .mount(&server)
                .await;
            let uri = server.uri();
            (uri, server)
        });
        scope.set("VAULT_ADDR", &uri);
        scope.set("VAULT_TOKEN", "s.dev-token");

        let value = VaultResolver::new()
            .resolve(&reference(
                "vault://secret/data/openai-prod?key=api_key&version=3",
            ))
            .expect("token auth resolves the field");
        assert_eq!(value, "sk-live-vault");
    }

    #[test]
    fn approle_login_obtains_a_token_then_reads_the_secret() {
        let scope = EnvScope::new(VAULT_VARS);
        let runtime = rt();
        let (uri, _server) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/v1/auth/approle/login"))
                .and(body_json(serde_json::json!({
                    "role_id": "role-abc",
                    "secret_id": "secret-xyz"
                })))
                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                    "auth": { "client_token": "s.approle-token" }
                })))
                .mount(&server)
                .await;
            Mock::given(method("GET"))
                .and(path("/v1/secret/data/db"))
                .and(header("x-vault-token", "s.approle-token"))
                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                    "data": { "data": { "password": "hunter2" } }
                })))
                .mount(&server)
                .await;
            let uri = server.uri();
            (uri, server)
        });
        scope.set("VAULT_ADDR", &uri);
        scope.set("VAULT_ROLE_ID", "role-abc");
        scope.set("VAULT_SECRET_ID", "secret-xyz");

        let value = VaultResolver::new()
            .resolve(&reference("vault://secret/data/db?key=password"))
            .expect("approle login then read resolves");
        assert_eq!(value, "hunter2");
    }

    #[test]
    fn fails_closed_without_any_credentials() {
        let scope = EnvScope::new(VAULT_VARS);
        scope.set("VAULT_ADDR", "http://127.0.0.1:1");
        let err = VaultResolver::new()
            .resolve(&reference("vault://secret/data/x?key=k"))
            .expect_err("no credentials must fail closed");
        assert!(err.0.contains("no Vault credentials"), "got: {}", err.0);
    }

    #[test]
    fn single_field_secret_resolves_without_a_key() {
        let body = serde_json::json!({ "data": { "data": { "only": "value-1" } } }).to_string();
        assert_eq!(extract_secret(&body, None).unwrap(), "value-1");
    }

    #[test]
    fn multi_field_secret_without_a_key_asks_for_one() {
        let body = serde_json::json!({ "data": { "data": { "a": "1", "b": "2" } } }).to_string();
        assert!(extract_secret(&body, None).is_err());
    }

    #[test]
    fn kv_v1_data_shape_is_read_when_v2_nesting_is_absent() {
        let body = serde_json::json!({ "data": { "token": "v1-secret" } }).to_string();
        assert_eq!(extract_secret(&body, Some("token")).unwrap(), "v1-secret");
    }
}
