//! Server, provider, and budget parsing for the suite loader.
//!
//! Holds the `servers:` / `providers:` / `budget:` parsers. Pulled out of
//! [`super`] so the parsing half stays under the AGENTS module-size ceiling.
//! All functions are crate-private; [`super::parse`] drives them.

use std::collections::BTreeMap;
use std::sync::OnceLock;

use regex::Regex;
use serde_yaml::Value as YamlValue;

use super::agent_types::{ProviderKind, ProviderSpec, SuiteBudget};
use super::types::ServerSpec;
use super::ParseError;

/// Parse the optional `providers:` block. The block is a
/// mapping keyed by user-chosen provider name; each value is a
/// [`ProviderSpec`]. Absent block returns an empty map.
pub(super) fn parse_providers(
    root: &YamlValue,
) -> Result<BTreeMap<String, ProviderSpec>, ParseError> {
    let Some(map) = root.get("providers").and_then(|v| v.as_mapping()) else {
        return Ok(BTreeMap::new());
    };
    let mut out = BTreeMap::new();
    for (key, value) in map {
        let name = key.as_str().ok_or(ParseError::BadShape {
            path: "/providers".to_string(),
            field: "provider key",
            expected: "string",
        })?;
        let path = format!("/providers/{name}");
        let spec = parse_provider_spec(name, value, &path)?;
        out.insert(name.to_string(), spec);
    }
    Ok(out)
}

fn parse_provider_spec(
    name: &str,
    value: &YamlValue,
    path: &str,
) -> Result<ProviderSpec, ParseError> {
    let map = value.as_mapping().ok_or(ParseError::BadShape {
        path: path.to_string(),
        field: "provider entry",
        expected: "mapping with type / base_url / api_key_env",
    })?;
    let kind = match map
        .get(YamlValue::String("type".into()))
        .and_then(|v| v.as_str())
    {
        Some("openai") => ProviderKind::OpenAi,
        Some(_) => {
            return Err(ParseError::BadShape {
                path: path.to_string(),
                field: "type",
                expected: "`openai` (only supported value today)",
            });
        }
        None => {
            return Err(ParseError::Missing {
                path: path.to_string(),
                field: "type",
            });
        }
    };
    let base_url = map
        .get(YamlValue::String("base_url".into()))
        .and_then(|v| v.as_str())
        .ok_or(ParseError::Missing {
            path: path.to_string(),
            field: "base_url",
        })?
        .to_string();
    let api_key_env = map
        .get(YamlValue::String("api_key_env".into()))
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let organization = map
        .get(YamlValue::String("organization".into()))
        .and_then(|v| v.as_str())
        .map(str::to_string);
    Ok(ProviderSpec {
        name: name.to_string(),
        kind,
        base_url,
        api_key_env,
        organization,
    })
}

pub(super) fn parse_agent_servers(
    entry: &YamlValue,
    path: &str,
) -> Result<Vec<String>, ParseError> {
    let seq = entry
        .get("servers")
        .and_then(|v| v.as_sequence())
        .ok_or(ParseError::Missing {
            path: path.to_string(),
            field: "servers",
        })?;
    if seq.is_empty() {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field: "servers",
            expected: "non-empty sequence of server names",
        });
    }
    let mut out = Vec::with_capacity(seq.len());
    for (i, item) in seq.iter().enumerate() {
        let s = item.as_str().ok_or(ParseError::BadShape {
            path: format!("{path}/servers/{i}"),
            field: "server reference",
            expected: "string",
        })?;
        out.push(s.to_string());
    }
    Ok(out)
}

pub(super) fn parse_budget(root: &YamlValue) -> Result<SuiteBudget, ParseError> {
    let Some(node) = root.get("budget") else {
        return Ok(SuiteBudget::default());
    };
    let mapping = node.as_mapping().ok_or(ParseError::BadShape {
        path: "/budget".to_string(),
        field: "budget",
        expected: "object",
    })?;
    let mut out = SuiteBudget::default();
    for (k, v) in mapping {
        let key = k.as_str().ok_or(ParseError::BadShape {
            path: "/budget".to_string(),
            field: "key",
            expected: "string",
        })?;
        let n = v.as_u64().ok_or(ParseError::BadShape {
            path: format!("/budget/{key}"),
            field: "cents",
            expected: "non-negative integer",
        })?;
        match key {
            "per_test_usd_cents" => out.per_test_usd_cents = Some(n),
            "per_suite_usd_cents" => out.per_suite_usd_cents = Some(n),
            _ => {
                // Unknown keys are tolerated so future budget knobs do
                // not break older parsers. The schema validates the
                // exact key set upstream.
            }
        }
    }
    Ok(out)
}

pub(super) fn parse_servers(root: &YamlValue) -> Result<BTreeMap<String, ServerSpec>, ParseError> {
    let mut servers = BTreeMap::new();
    // Suite-level `auth:` default. A URL server without its own `auth:` block
    // inherits this (per-server override beats it). Mirrors collection-level
    // auth in Postman/Bruno.
    let default_auth = root.get("auth");
    let Some(mapping) = root.get("servers").and_then(|v| v.as_mapping()) else {
        return Ok(servers);
    };
    for (name_node, value) in mapping {
        let name = name_node
            .as_str()
            .ok_or_else(|| ParseError::BadShape {
                path: "/servers".to_string(),
                field: "key",
                expected: "string",
            })?
            .to_string();
        let path = format!("/servers/{name}");
        let has_command = value.get("command").is_some();
        let has_url = value.get("url").is_some();
        let has_cassette = value.get("cassette").is_some();
        // A server is reached exactly one way: spawn a process, hit a URL,
        // or replay a cassette. `command:` and `url:` together is always
        // ambiguous; either may sit next to `cassette:` as the refresh
        // target `--update-cassettes` re-records from (WOR-1279).
        if has_command && has_url {
            return Err(ParseError::AmbiguousServer { name });
        }
        let spec = if has_cassette {
            parse_cassette_server(value, &path)?
        } else if has_command {
            parse_stdio_server(value, &path)?
        } else if has_url {
            parse_http_server(value, &path, default_auth)?
        } else {
            return Err(ParseError::Missing {
                path,
                field: "command, url, or cassette",
            });
        };
        servers.insert(name, spec);
    }
    Ok(servers)
}

/// Build a [`ServerSpec::Stdio`] from a server entry that carries a
/// `command:` array. Pulled out of [`parse_servers`] to keep that function
/// under the 60-line AGENTS cap.
fn parse_stdio_server(value: &YamlValue, path: &str) -> Result<ServerSpec, ParseError> {
    let argv = parse_command_argv(value, path)?;
    let env = parse_env_block(value.get("env"), path)?;
    Ok(ServerSpec::Stdio {
        command: argv[0].clone(),
        args: argv[1..].to_vec(),
        env,
    })
}

/// Parse a `command:` array into a non-empty argv. Shared by the stdio
/// server form and the cassette refresh target (WOR-1279) so both reject
/// the same malformed shapes with the same message.
fn parse_command_argv(value: &YamlValue, path: &str) -> Result<Vec<String>, ParseError> {
    let cmd_list = value
        .get("command")
        .and_then(|c| c.as_sequence())
        .ok_or_else(|| ParseError::BadShape {
            path: path.to_string(),
            field: "command",
            expected: "sequence of strings",
        })?;
    let mut argv: Vec<String> = Vec::with_capacity(cmd_list.len());
    for (i, arg) in cmd_list.iter().enumerate() {
        let s = arg.as_str().ok_or_else(|| ParseError::BadShape {
            path: format!("{path}/command/{i}"),
            field: "command argv",
            expected: "string",
        })?;
        // A secret reference in argv would be exposed to any local user
        // through `ps`/proc listings, so it is rejected rather than
        // resolved. Only known secret schemes are rejected; an ordinary
        // URL or a custom resource URI in args (a `file://` path, a
        // `grpc://` endpoint, an `issues://` resource) stays allowed.
        if is_secret_reference(s) {
            return Err(ParseError::ReferenceInArgs {
                path: format!("{path}/command/{i}"),
                value: s.to_string(),
            });
        }
        argv.push(s.to_string());
    }
    if argv.is_empty() {
        return Err(ParseError::Missing {
            path: path.to_string(),
            field: "command",
        });
    }
    Ok(argv)
}

/// Secret-reference schemes that must never appear in argv, because a
/// resolved credential on a command line leaks through `ps`/proc
/// listings. Kept as a small allowlist (not "any non-reserved scheme")
/// so an ordinary custom URI in args, such as an MCP resource URI like
/// `issues://123` or a `grpc://` endpoint, is not rejected. Mirrors the
/// secret schemes `mcptest-config` resolves; duplicated here because
/// `mcptest-config` depends on `mcptest-core`, so the reverse dependency
/// would form a cycle.
const SECRET_ARG_SCHEMES: [&str; 3] = ["vault", "awssm", "gcpsm"];

/// Whether `value` is a whole-value reference to a known secret scheme.
///
/// Only the schemes in [`SECRET_ARG_SCHEMES`] count; anything else
/// (ordinary URLs, custom resource URIs) is allowed in argv. Gates
/// command lines, where a resolved secret would leak through process
/// listings.
fn is_secret_reference(value: &str) -> bool {
    let Some(caps) = arg_scheme_pattern().captures(value) else {
        return false;
    };
    let Some(scheme) = caps.get(1).map(|m| m.as_str().to_ascii_lowercase()) else {
        return false;
    };
    SECRET_ARG_SCHEMES.contains(&scheme.as_str())
}

/// Anchored `scheme://` recognizer for argv elements, compiled once.
/// Anchored at the start so an embedded scheme inside a longer argument
/// never matches; only a value that opens with `scheme://` is a candidate.
fn arg_scheme_pattern() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r"(?i)^([a-z][a-z0-9+.-]*)://").expect("argv scheme regex is valid")
    })
}

/// Build a [`ServerSpec::Cassette`] from a server entry that carries a
/// `cassette:` path. The run replays the recorded exchanges instead of
/// reaching a live server. An optional `command:` or `url:` next to the
/// cassette declares the live target `--update-cassettes` re-records from
/// (WOR-1279); replay itself never touches it.
fn parse_cassette_server(value: &YamlValue, path: &str) -> Result<ServerSpec, ParseError> {
    let cassette = value
        .get("cassette")
        .and_then(|c| c.as_str())
        .ok_or_else(|| ParseError::BadShape {
            path: path.to_string(),
            field: "cassette",
            expected: "string path",
        })?;
    let refresh_command = value
        .get("command")
        .map(|_| parse_command_argv(value, path))
        .transpose()?;
    let refresh_url = value
        .get("url")
        .map(|u| {
            u.as_str()
                .map(str::to_string)
                .ok_or_else(|| ParseError::BadShape {
                    path: path.to_string(),
                    field: "url",
                    expected: "string",
                })
        })
        .transpose()?;
    Ok(ServerSpec::Cassette {
        path: cassette.to_string(),
        refresh_command,
        refresh_url,
    })
}

/// Build a [`ServerSpec::StreamableHttp`] or [`ServerSpec::LegacySse`] from a
/// server entry that carries a `url:`. Pulled out of [`parse_servers`] to keep
/// that function under the 60-line AGENTS cap.
fn parse_http_server(
    value: &YamlValue,
    path: &str,
    default_auth: Option<&YamlValue>,
) -> Result<ServerSpec, ParseError> {
    let url = value
        .get("url")
        .and_then(|u| u.as_str())
        .ok_or_else(|| ParseError::BadShape {
            path: path.to_string(),
            field: "url",
            expected: "string",
        })?
        .to_string();
    let transport = value
        .get("transport")
        .and_then(|t| t.as_str())
        .unwrap_or("streamable-http");
    // A server's own `auth:` wins; otherwise it inherits the suite-level
    // `auth:` default. The whole block is inherited or overridden as a unit
    // (no partial merge), so precedence stays predictable.
    let auth = value.get("auth").or(default_auth);
    let mut headers = parse_env_block(value.get("headers"), path)?;
    apply_bearer_auth(auth, &mut headers);
    let oauth = parse_oauth_client_credentials(auth, path)?;
    Ok(match transport {
        "legacy-sse" => ServerSpec::LegacySse {
            url,
            headers,
            oauth,
        },
        // `stateless-http` opts into the 2026-07-28 stateless streamable-HTTP
        // shape; `streamable-http` (the default) stays session-based (WOR-1383).
        "stateless-http" => ServerSpec::StreamableHttp {
            url,
            headers,
            oauth,
            stateless: true,
        },
        _ => ServerSpec::StreamableHttp {
            url,
            headers,
            oauth,
            stateless: false,
        },
    })
}

/// Parse an `auth.oauth` client-credentials block, if present. Returns `None`
/// when there is no `auth.oauth`. Only the `client_credentials` grant is
/// supported today; any other grant is a clear error.
fn parse_oauth_client_credentials(
    auth: Option<&YamlValue>,
    path: &str,
) -> Result<Option<crate::suite::OAuthClientCredentials>, ParseError> {
    let Some(oauth) = auth.and_then(|a| a.get("oauth")) else {
        return Ok(None);
    };
    let oauth_path = format!("{path}/auth/oauth");
    let grant = oauth.get("grant").and_then(|g| g.as_str()).unwrap_or("");
    if grant != "client_credentials" {
        return Err(ParseError::BadShape {
            path: oauth_path,
            field: "grant",
            expected: "client_credentials (the only supported grant)",
        });
    }
    let req_str = |field: &'static str| -> Result<String, ParseError> {
        oauth
            .get(field)
            .and_then(|v| v.as_str())
            .map(str::to_string)
            .ok_or_else(|| ParseError::Missing {
                path: oauth_path.clone(),
                field,
            })
    };
    Ok(Some(crate::suite::OAuthClientCredentials {
        token_endpoint: req_str("token_endpoint")?,
        client_id: req_str("client_id")?,
        client_secret_env: req_str("client_secret_env")?,
        scope: oauth
            .get("scope")
            .and_then(|s| s.as_str())
            .map(str::to_string),
    }))
}

/// Materialize `auth.bearer_token_env` into an `Authorization: Bearer <token>`
/// header so the bearer actually reaches the wire.
///
/// The HTTP/SSE transports carry `headers` but no separate auth field, so a
/// bearer declared under `auth:` (directly or via the `--server-auth-bearer-env`
/// override, which writes the same block) must be turned into a header here or
/// it is silently dropped on the run/connect path. The env var is resolved at
/// parse time, which runs immediately before connect with the environment
/// already loaded. A missing env var leaves no header (the server's 401
/// surfaces the problem); an explicit `headers.Authorization` is left untouched.
///
/// WOR-1453 limitation: `bearer_token_env` (like `auth.oauth.client_secret_env`)
/// names an env var read straight from `std::env` here, so it bypasses the
/// run-path secret-resolver registry; a `vault://`-style reference cannot be
/// used in this field today. Resolving the env value through the registry would
/// require threading it into the typed parse, so to avoid mutating `std::env`
/// it is left on the literal path. Authors who want a resolved reference put it
/// in a `headers.Authorization` value, which interpolation does resolve.
fn apply_bearer_auth(
    auth: Option<&YamlValue>,
    headers: &mut std::collections::BTreeMap<String, String>,
) {
    let Some(env_name) = auth
        .and_then(|a| a.get("bearer_token_env"))
        .and_then(|v| v.as_str())
    else {
        return;
    };
    if let Ok(token) = std::env::var(env_name) {
        headers
            .entry("Authorization".to_string())
            .or_insert_with(|| format!("Bearer {token}"));
    }
}

fn parse_env_block(
    block: Option<&YamlValue>,
    parent_path: &str,
) -> Result<BTreeMap<String, String>, ParseError> {
    let mut out = BTreeMap::new();
    let Some(mapping) = block.and_then(|v| v.as_mapping()) else {
        return Ok(out);
    };
    for (k, v) in mapping {
        let key = k.as_str().ok_or_else(|| ParseError::BadShape {
            path: format!("{parent_path}/env"),
            field: "key",
            expected: "string",
        })?;
        let val = v.as_str().ok_or_else(|| ParseError::BadShape {
            path: format!("{parent_path}/env/{key}"),
            field: "value",
            expected: "string",
        })?;
        out.insert(key.to_string(), val.to_string());
    }
    Ok(out)
}
