//! Shared variable-interpolation entry point for the live `run` / `eval`
//! paths.
//!
//! `interpolate_run_config` resolves `${...}` references but returns the
//! unset-variable warnings on its context. The run/eval handlers used to
//! discard that context (`let (yaml, _ctx) = ...`), so a bare `${NAME}`
//! that matched neither the `variables:` block nor an environment variable
//! was silently substituted with the empty string and nothing was ever
//! reported. That hid typos like `${ACCOUNT_I}` for `${ACCOUNT_ID}`.
//!
//! This wrapper keeps the lenient default (so intentionally optional
//! references like `${EXTRA_ARGS:-}` still work) but never stays silent:
//!
//! - default: one warning to stderr listing every unresolved name.
//! - `MCPTEST_STRICT_VARS=1`: a hard error instead, so CI fails closed.

use std::sync::Arc;

use serde_yaml::Value as YamlValue;

use mcptest_config::secrets::SecretRegistry;

/// Build the secret-resolver registry the run path installs on its
/// interpolation context (WOR-1453).
///
/// Why a builder here rather than a shared static: the registry holds live
/// backend clients, so it is constructed per invocation and threaded
/// explicitly into interpolation, never parked in process-global state. The
/// concrete backends (`vault://`, `awssm://`, `gcpsm://`) are registered by
/// [`super::secret_backends::register_all`]; a scheme nobody resolves stays
/// literal rather than failing the load, so adding backends never reinterprets
/// an existing config value.
fn build_secret_registry() -> Arc<SecretRegistry> {
    let mut registry = SecretRegistry::new();
    super::secret_backends::register_all(&mut registry);
    Arc::new(registry)
}

/// Interpolate `${...}` references for the run/eval path and report any
/// unset bare `${NAME}` references (warn by default, error under
/// `MCPTEST_STRICT_VARS`). `config_path` feeds the built-in
/// `${CONFIG_DIR}` variable so suite-relative paths resolve from any CWD.
///
/// This entry point drops the resolved-secret plaintext, so callers that do
/// not own an output sink (collect-only, `--explain`, the live-eval
/// pre-pass) stay unchanged. The run path uses
/// [`interpolate_run_config_for_run`] instead so it can scrub those values.
pub(crate) fn interpolate_run_config_reported(
    yaml: YamlValue,
    config_path: &std::path::Path,
    quiet: bool,
) -> anyhow::Result<YamlValue> {
    let (yaml, _secrets) = interpolate_run_config_for_run(yaml, config_path, quiet)?;
    Ok(yaml)
}

/// Interpolate like [`interpolate_run_config_reported`] but also return the
/// plaintext of every resolved secret (scheme references plus `${secret:}`
/// values) so the run handler can hand them to the redactor (WOR-1454).
///
/// The values are returned by value, not stashed anywhere, so the secret
/// list lives only as long as the run that threads it into its sinks.
pub(crate) fn interpolate_run_config_for_run(
    yaml: YamlValue,
    config_path: &std::path::Path,
    quiet: bool,
) -> anyhow::Result<(YamlValue, Vec<String>)> {
    let registry = build_secret_registry();
    let (yaml, ctx) = mcptest_config::run_interpolate::interpolate_run_config_with_registry(
        yaml,
        config_path,
        Some(registry),
    )
    .map_err(|e| {
        anyhow::anyhow!(
            "variable interpolation failed: {e}\nsee: {}",
            mcptest_core::error_anchors::VARIABLE_INTERPOLATION
        )
    })?;
    report_unset(&ctx.take_unset_warnings(), strict_vars(), quiet)?;
    // Both the scheme-reference plaintext and the `${secret:}` values must be
    // scrubbed from output, so drain both onto one list for the redactor.
    let mut secrets = ctx.take_resolved_references();
    secrets.extend(ctx.take_resolved_secrets());
    Ok((yaml, secrets))
}

/// Warn about (or, when `strict`, reject) unset bare `${NAME}` references.
/// Pure decision split out from the interpolation so it can be tested
/// without mutating the process environment.
fn report_unset(unset: &[String], strict: bool, quiet: bool) -> anyhow::Result<()> {
    if unset.is_empty() {
        return Ok(());
    }
    if strict {
        anyhow::bail!(
            "unset variable(s) with no default resolved to empty: {}. \
             Declare them under `variables:`, export them, or write \
             `${{NAME:-}}` for an intentionally empty value \
             (MCPTEST_STRICT_VARS is set, so this is an error).",
            unset.join(", ")
        );
    }
    // The unset-var warning is safety-critical: an empty value can become an
    // empty auth header that silently fails open. Emit it to stderr even under
    // --quiet (stdout stays clean for machine reporters); the verbose
    // remediation hint stays behind --quiet. Set MCPTEST_STRICT_VARS=1 to make
    // it a hard error instead.
    eprintln!(
        "mcptest: warning: unset variable(s) resolved to empty string: {}",
        unset.join(", ")
    );
    if !quiet {
        eprintln!(
            "  declare them under `variables:`, export them, or use `${{NAME:-}}` \
             to make the empty value explicit (or set MCPTEST_STRICT_VARS=1 to fail)"
        );
    }
    Ok(())
}

/// Whether unset bare variables should be a hard error. Driven by
/// `MCPTEST_STRICT_VARS` (any non-empty value other than `0`).
fn strict_vars() -> bool {
    std::env::var_os("MCPTEST_STRICT_VARS")
        .map(|v| !v.is_empty() && v != "0")
        .unwrap_or(false)
}

#[cfg(test)]
mod tests {
    use super::{interpolate_run_config_reported, report_unset};

    #[test]
    fn failed_interpolation_links_the_troubleshooting_entry() {
        // `${NAME:?}` on a variable that cannot be set fails interpolation;
        // the wrapped error must point at the env-var troubleshooting entry.
        let yaml: serde_yaml::Value = serde_yaml::from_str(
            "servers:\n  main:\n    command: [\"echo\", \"${WOR1268_UNSET_FOR_TEST:?}\"]\n",
        )
        .unwrap();
        std::env::remove_var("WOR1268_UNSET_FOR_TEST");
        let err = interpolate_run_config_reported(yaml, std::path::Path::new("mcptest.yml"), true)
            .expect_err("a required unset variable must fail")
            .to_string();
        assert!(err.contains("variable interpolation failed"), "{err}");
        assert!(
            err.contains(mcptest_core::error_anchors::VARIABLE_INTERPOLATION),
            "{err}"
        );
    }

    #[test]
    fn no_unset_vars_is_ok_in_every_mode() {
        assert!(report_unset(&[], true, false).is_ok());
        assert!(report_unset(&[], false, false).is_ok());
    }

    #[test]
    fn strict_mode_errors_and_names_every_unset_var() {
        let unset = vec!["ACCOUNT_ID".to_string(), "REGION".to_string()];
        let err = report_unset(&unset, true, true).unwrap_err().to_string();
        assert!(err.contains("ACCOUNT_ID"), "names the first var: {err}");
        assert!(err.contains("REGION"), "names the second var: {err}");
    }

    #[test]
    fn lenient_mode_warns_but_succeeds() {
        let unset = vec!["ACCOUNT_ID".to_string()];
        assert!(report_unset(&unset, false, true).is_ok());
    }
}
