//! Environment variable formatting and injection utilities.
//!
//! Provides format functions for rendering vault secrets as shell-evaluable env
//! assignments (`export KEY="value"`), GitHub Actions workflow commands, and
//! PowerShell syntax.  Also implements the `exec` env-injection path: building a
//! child-process environment that strips sensitive tsafe-internal vars before
//! adding vault secrets.

use std::collections::HashMap;
use std::path::Path;
use std::process::Command;

use crate::{
    errors::{SafeError, SafeResult},
    profile,
};

// ── formatting ───────────────────────────────────────────────────────────────

/// KEY=VALUE per line (POSIX env assign syntax).
pub fn format_env(secrets: &HashMap<String, String>) -> String {
    sorted_pairs(secrets)
        .map(|(k, v)| format!("{k}={}", v.replace('\n', "\\n").replace('\r', "\\r")))
        .collect::<Vec<_>>()
        .join("\n")
}

/// `export KEY="VALUE"` per line (bash/zsh source-able).
/// Escapes backslashes, double-quotes, dollar signs, backticks, and newlines.
pub fn format_dotenv(secrets: &HashMap<String, String>) -> String {
    sorted_pairs(secrets)
        .map(|(k, v)| {
            let escaped = v
                .replace('\\', "\\\\")
                .replace('"', "\\\"")
                .replace('$', "\\$")
                .replace('`', "\\`")
                .replace('\n', "\\n")
                .replace('\r', "\\r");
            format!("export {k}=\"{escaped}\"")
        })
        .collect::<Vec<_>>()
        .join("\n")
}

/// `$env:KEY = "VALUE"` per line (PowerShell source-able).
/// Escapes double-quotes, backticks, dollar signs, and newlines for safe evaluation.
pub fn format_powershell(secrets: &HashMap<String, String>) -> String {
    sorted_pairs(secrets)
        .map(|(k, v)| {
            let escaped = v
                .replace('`', "``")
                .replace('"', "`\"")
                .replace('$', "`$")
                .replace('\n', "`n")
                .replace('\r', "`r");
            format!("$env:{k} = \"{escaped}\"")
        })
        .collect::<Vec<_>>()
        .join("\n")
}

/// JSON object `{ "KEY": "VALUE", … }`.
pub fn format_json(secrets: &HashMap<String, String>) -> SafeResult<String> {
    serde_json::to_string_pretty(secrets).map_err(SafeError::Serialization)
}

/// YAML mapping: one `KEY: "VALUE"` per line, sorted by key.
///
/// Values are double-quoted and special characters escaped so the output is
/// always valid YAML without a dependency on a full YAML serialiser for this
/// simple key→string mapping shape.
pub fn format_yaml(secrets: &HashMap<String, String>) -> SafeResult<String> {
    let lines: Vec<String> = {
        let mut pairs: Vec<(&str, &str)> = secrets
            .iter()
            .map(|(k, v)| (k.as_str(), v.as_str()))
            .collect();
        pairs.sort_by_key(|(k, _)| *k);
        pairs
            .into_iter()
            .map(|(k, v)| {
                // Escape backslash, double-quote, and newlines for YAML double-quoted scalar.
                let escaped = v
                    .replace('\\', "\\\\")
                    .replace('"', "\\\"")
                    .replace('\n', "\\n")
                    .replace('\r', "\\r");
                format!("{k}: \"{escaped}\"")
            })
            .collect()
    };
    Ok(lines.join("\n"))
}

/// Docker `--env-file` format: `KEY=VALUE` per line, sorted by key.
///
/// Docker env-file syntax is identical to POSIX `KEY=VALUE`, without `export`
/// prefixes. Values containing newlines have them escaped as `\n` / `\r` so
/// each pair stays on one line.
pub fn format_docker_env(secrets: &HashMap<String, String>) -> String {
    sorted_pairs(secrets)
        .map(|(k, v)| format!("{k}={}", v.replace('\n', "\\n").replace('\r', "\\r")))
        .collect::<Vec<_>>()
        .join("\n")
}

/// GitHub Actions format: `::add-mask::VALUE` workflow command followed by
/// `KEY=VALUE` for each secret, sorted by key.
///
/// Append to `$GITHUB_ENV` in a `run:` step:
/// ```yaml
/// - run: tsafe export --format github-actions >> $GITHUB_ENV
/// ```
/// The runner processes `::add-mask::` lines to mask values in log output, and
/// `KEY=VALUE` lines are loaded into subsequent steps automatically.
pub fn format_github_actions(secrets: &HashMap<String, String>) -> String {
    sorted_pairs(secrets)
        .flat_map(|(k, v)| {
            let safe_v = v.replace('\n', "%0A").replace('\r', "%0D");
            [format!("::add-mask::{safe_v}"), format!("{k}={safe_v}")]
        })
        .collect::<Vec<_>>()
        .join("\n")
}

/// TOML flat top-level table: `KEY = "VALUE"` per line, sorted by key.
///
/// Bare TOML keys are `[A-Za-z0-9_-]+`; any key that does not satisfy that
/// pattern is quoted with double-quotes.  Values are TOML basic strings:
/// backslashes are escaped as `\\` and double-quotes as `\"`.
pub fn format_toml(pairs: &[(impl AsRef<str>, impl AsRef<str>)]) -> String {
    let mut sorted: Vec<(&str, &str)> = pairs
        .iter()
        .map(|(k, v)| (k.as_ref(), v.as_ref()))
        .collect();
    sorted.sort_by_key(|(k, _)| *k);
    sorted
        .into_iter()
        .map(|(k, v)| {
            let key = if is_bare_toml_key(k) {
                k.to_owned()
            } else {
                format!("\"{}\"", escape_toml_string(k))
            };
            let value = escape_toml_string(v);
            format!("{key} = \"{value}\"")
        })
        .collect::<Vec<_>>()
        .join("\n")
}

/// Returns true when `k` is a valid TOML bare key (`[A-Za-z0-9_-]+`).
fn is_bare_toml_key(k: &str) -> bool {
    !k.is_empty()
        && k.chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}

/// Escape a string for use inside TOML double-quoted basic strings.
/// Escapes: `\` → `\\`, `"` → `\"`.
fn escape_toml_string(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

fn sorted_pairs(m: &HashMap<String, String>) -> impl Iterator<Item = (&str, &str)> {
    let mut pairs: Vec<(&str, &str)> = m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
    pairs.sort_by_key(|(k, _)| *k);
    pairs.into_iter()
}

// ── .env import ──────────────────────────────────────────────────────────────

/// Parse a `.env` file into a `HashMap`. Handles `#` comments, blank lines,
/// Parse a `.env`-style file into a key→value map.
///
/// Handles the following line forms (and their whitespace-padded variants):
/// - `KEY=VALUE`                  (POSIX / dotenv)
/// - `KEY="quoted"` / `KEY='q'`   (quoted values, quotes are stripped)
/// - `export KEY=VALUE`           (bash/zsh source-able)
/// - `$KEY = VALUE`               (PowerShell variable style)
/// - Lines starting with `#`      (comments, skipped)
/// - Lines without `=`            (free-form text / section headers, silently skipped)
///
/// The parser is intentionally lenient: real-world `.env` files often contain
/// freeform notes or section headers mixed with assignments.
///
/// Two-pass parsing is used so that intra-file `$VAR` references resolve correctly
/// even when the referenced variable is defined earlier in the same file (e.g.
/// `export ARM_CLIENT_ID="..."` followed by `client_id="$ARM_CLIENT_ID"`).
/// Resolution order: process environment first, then values from this file.
pub fn parse_dotenv(path: &Path) -> SafeResult<HashMap<String, String>> {
    let raw_content = std::fs::read_to_string(path).map_err(|e| SafeError::ImportParse {
        file: path.display().to_string(),
        reason: e.to_string(),
    })?;
    // Strip UTF-8 BOM if present (common with Windows Notepad-saved files).
    let content = raw_content.strip_prefix('\u{FEFF}').unwrap_or(&raw_content);

    // Pass 1 — collect raw (un-expanded) key/value pairs.
    let mut raw_pairs: Vec<(String, String)> = Vec::new();
    for raw in content.lines() {
        let line = raw.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let Some(eq) = line.find('=') else { continue };
        let raw_key = line[..eq].trim();
        let raw_key = raw_key
            .strip_prefix("export")
            .map(str::trim)
            .unwrap_or(raw_key);
        let raw_key = raw_key.strip_prefix('$').unwrap_or(raw_key);
        let key = raw_key.trim().to_string();
        if key.is_empty() || key.contains(|c: char| c.is_whitespace()) {
            continue;
        }
        let raw_val = strip_quotes(line[eq + 1..].trim()).to_string();
        raw_pairs.push((key, raw_val));
    }

    // Build a lookup of literal (non-$VAR) values from this file for intra-file
    // reference resolution in pass 2.
    let mut literals: HashMap<String, String> = HashMap::new();
    for (k, v) in &raw_pairs {
        if !v.starts_with('$') {
            literals.insert(k.clone(), v.clone());
        }
    }

    // Pass 2 — resolve $VAR references: process env first, then intra-file literals.
    let mut map = HashMap::new();
    for (key, val) in raw_pairs {
        let resolved = resolve_env_ref(&val, &literals);
        map.insert(key, resolved);
    }
    Ok(map)
}

fn strip_quotes(s: &str) -> &str {
    if s.len() >= 2
        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
    {
        &s[1..s.len() - 1]
    } else {
        s
    }
}

/// Resolve a bare `$VARNAME` reference.
/// Lookup order: process environment → intra-file literals.
/// Only exact whole-value references are expanded (e.g. `$ARM_CLIENT_ID`).
/// If the variable is not found in either source, the literal value is kept.
fn resolve_env_ref(val: &str, file_locals: &HashMap<String, String>) -> String {
    if let Some(name) = val.strip_prefix('$') {
        if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
            if let Ok(resolved) = std::env::var(name) {
                return resolved;
            }
            if let Some(resolved) = file_locals.get(name) {
                return resolved.clone();
            }
        }
    }
    val.to_string()
}

// ── process execution ────────────────────────────────────────────────────────

/// Sensitive environment variables that must never be passed to child processes.
/// The master password and credential vars are stripped before exec.
const SENSITIVE_VARS: &[&str] = &[
    "TSAFE_PASSWORD",
    "TSAFE_NEW_MASTER_PASSWORD",
    "AZURE_CLIENT_SECRET",
    "VAULT_TOKEN",
    "TSAFE_AKV_URL",
    "TSAFE_HCP_URL",
    "AWS_SECRET_ACCESS_KEY",
    "AWS_SESSION_TOKEN",
    // PAT-style tokens commonly present in CI and developer environments
    "ADO_PAT",
    "ADO_PAT2",
    "GITHUB_TOKEN",
    "GH_TOKEN",
    "GITLAB_TOKEN",
    "NPM_TOKEN",
    "PYPI_TOKEN",
    "NUGET_API_KEY",
];

/// Env var names that can hijack dynamic loaders or language runtimes when injected into a child.
/// Used by `tsafe exec` to warn or optionally refuse injection ([`is_dangerous_injected_env_name`]).
const DANGEROUS_INJECTED_ENV_NAMES: &[&str] = &[
    "LD_PRELOAD",
    "LD_LIBRARY_PATH",
    "NODE_OPTIONS",
    "DYLD_INSERT_LIBRARIES",
    "DYLD_LIBRARY_PATH",
    "DYLD_FRAMEWORK_PATH",
];

/// Returns true if this env var name is known to affect loaders or interpreters (ASCII case-insensitive).
pub fn is_dangerous_injected_env_name(name: &str) -> bool {
    DANGEROUS_INJECTED_ENV_NAMES
        .iter()
        .any(|d| d.eq_ignore_ascii_case(name))
}

/// Returns the list of parent environment variable names that `tsafe exec` strips before
/// spawning the child process, including config-driven extras. Used by `--plan` to show which
/// names would be scrubbed.
pub fn sensitive_parent_env_vars() -> Vec<String> {
    let mut out = Vec::new();
    for name in SENSITIVE_VARS
        .iter()
        .map(|name| (*name).to_string())
        .chain(profile::get_exec_extra_sensitive_parent_vars())
    {
        if !out
            .iter()
            .any(|existing: &String| existing.eq_ignore_ascii_case(&name))
        {
            out.push(name);
        }
    }
    out
}

/// Minimal set of parent env vars that most commands need for basic operation.
///
/// Used by `tsafe exec --minimal` as a curated safe baseline: no tokens, no credentials,
/// no loader-hijack vars — just the things that break commands when absent.
pub const MINIMAL_ENV_VARS: &[&str] = &[
    // Shell / process identity
    "PATH",
    "HOME",
    "USER",
    "LOGNAME",
    "SHELL",
    // Temp directories
    "TMPDIR",
    "TMP",
    "TEMP",
    // Locale / encoding
    "LANG",
    "LC_ALL",
    "LC_CTYPE",
    "LC_MESSAGES",
    // Terminal (needed by CLI tools that detect color/width)
    "TERM",
    "TERM_PROGRAM",
    "COLORTERM",
    "NO_COLOR",
    "FORCE_COLOR",
    // Working directory hint (set by the shell; not always present)
    "PWD",
    // SSH agent socket (needed for git-over-SSH inside the child)
    "SSH_AUTH_SOCK",
    "SSH_AGENT_PID",
    // Linux desktop / XDG (needed by GUI tools and some CLI tools for config dirs)
    "DISPLAY",
    "WAYLAND_DISPLAY",
    "XDG_RUNTIME_DIR",
    "XDG_CONFIG_HOME",
    "XDG_DATA_HOME",
    "XDG_CACHE_HOME",
];

fn apply_exec_environment(
    cmd: &mut Command,
    secrets: &HashMap<String, String>,
    extra_strip_names: &[String],
) {
    // Strip sensitive vars from the inherited parent environment before injecting secrets.
    let mut strip_names = sensitive_parent_env_vars();
    for name in extra_strip_names {
        if !strip_names
            .iter()
            .any(|existing| existing.eq_ignore_ascii_case(name))
        {
            strip_names.push(name.clone());
        }
    }
    for var in strip_names {
        cmd.env_remove(var);
    }
    for (k, v) in secrets {
        cmd.env(k, v);
    }
}

/// Build a command with the inherited parent env (minus sensitive strips) plus vault secrets.
pub fn command_with_secrets(
    secrets: &HashMap<String, String>,
    cmd_parts: &[String],
) -> SafeResult<Command> {
    command_with_secrets_and_extra_strips(secrets, &[], cmd_parts)
}

/// Build a command with the inherited parent env (minus sensitive strips and `extra_strip_names`)
/// plus vault secrets.
pub fn command_with_secrets_and_extra_strips(
    secrets: &HashMap<String, String>,
    extra_strip_names: &[String],
    cmd_parts: &[String],
) -> SafeResult<Command> {
    if cmd_parts.is_empty() {
        return Err(SafeError::InvalidVault {
            reason: "no command provided for exec".into(),
        });
    }
    let mut cmd = Command::new(&cmd_parts[0]);
    cmd.args(&cmd_parts[1..]);
    apply_exec_environment(&mut cmd, secrets, extra_strip_names);
    Ok(cmd)
}

/// Build a command from a clean environment, adding back only `keep`, then vault secrets.
pub fn clean_env_command(
    secrets: &HashMap<String, String>,
    keep: &HashMap<String, String>,
    cmd_parts: &[String],
) -> SafeResult<Command> {
    if cmd_parts.is_empty() {
        return Err(SafeError::InvalidVault {
            reason: "no command provided for exec".into(),
        });
    }
    let mut cmd = Command::new(&cmd_parts[0]);
    cmd.args(&cmd_parts[1..]);
    cmd.env_clear();
    for (k, v) in keep {
        cmd.env(k, v);
    }
    for (k, v) in secrets {
        cmd.env(k, v);
    }
    Ok(cmd)
}

/// Spawn `cmd_parts[0]` with `cmd_parts[1..]` as arguments, injecting `secrets`
/// into its environment (on top of the inherited parent env). Returns exit code.
pub fn exec_with_secrets(
    secrets: &HashMap<String, String>,
    cmd_parts: &[String],
) -> SafeResult<i32> {
    let mut cmd = command_with_secrets(secrets, cmd_parts)?;
    let status = cmd.status()?;
    Ok(status.code().unwrap_or(1))
}

/// Like [`exec_with_secrets`] but starts from a clean environment (no parent env inherited),
/// then adds back only the `keep` entries from the parent, and finally injects `secrets`.
///
/// Used by `tsafe exec --no-inherit` and `tsafe exec --only KEY,...`.
pub fn exec_clean_env(
    secrets: &HashMap<String, String>,
    keep: &HashMap<String, String>,
    cmd_parts: &[String],
) -> SafeResult<i32> {
    let mut cmd = clean_env_command(secrets, keep, cmd_parts)?;
    let status = cmd.status()?;
    Ok(status.code().unwrap_or(1))
}

// ── tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    fn sample() -> HashMap<String, String> {
        let mut m = HashMap::new();
        m.insert("ZZZ".into(), "z".into());
        m.insert("AAA".into(), "a".into());
        m.insert("MMM".into(), "m with \"quotes\"".into());
        m
    }

    #[test]
    fn format_toml_bare_keys_and_basic_string_values() {
        let pairs = vec![
            ("ZZZ".to_string(), "z".to_string()),
            ("AAA".to_string(), "a".to_string()),
            (
                "MY_KEY".to_string(),
                "val with \"quotes\" and \\backslash".to_string(),
            ),
        ];
        let out = format_toml(&pairs);
        let lines: Vec<&str> = out.lines().collect();
        // Sorted order: AAA, MY_KEY, ZZZ
        assert_eq!(lines[0], "AAA = \"a\"");
        assert_eq!(
            lines[1],
            r#"MY_KEY = "val with \"quotes\" and \\backslash""#
        );
        assert_eq!(lines[2], "ZZZ = \"z\"");
    }

    #[test]
    fn format_toml_non_bare_key_is_quoted() {
        // hyphen is allowed in bare keys, space and dot are not
        let pairs = vec![
            ("my-key".to_string(), "v1".to_string()),
            ("my key".to_string(), "v2".to_string()),
            ("my.key".to_string(), "v3".to_string()),
        ];
        let out = format_toml(&pairs);
        assert!(out.contains("my-key = \"v1\""), "hyphen key must be bare");
        assert!(
            out.contains("\"my key\" = \"v2\""),
            "space key must be quoted"
        );
        assert!(
            out.contains("\"my.key\" = \"v3\""),
            "dot key must be quoted"
        );
    }

    #[test]
    fn format_toml_empty_input_produces_empty_string() {
        let pairs: Vec<(String, String)> = vec![];
        assert_eq!(format_toml(&pairs), "");
    }

    #[test]
    fn format_env_sorted_output() {
        let out = format_env(&sample());
        let lines: Vec<&str> = out.lines().collect();
        assert_eq!(lines[0], "AAA=a");
        assert!(lines[2].starts_with("ZZZ="));
    }

    #[test]
    fn format_json_valid() {
        let json = format_json(&sample()).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed["AAA"], "a");
    }

    #[test]
    fn format_env_escapes_newlines_and_carriage_returns() {
        let mut secrets = HashMap::new();
        secrets.insert("MULTI".into(), "line1\nline2\rline3".into());
        assert_eq!(format_env(&secrets), "MULTI=line1\\nline2\\rline3");
    }

    #[test]
    fn format_github_actions_escapes_newlines_and_carriage_returns() {
        let mut secrets = HashMap::new();
        secrets.insert("MULTI".into(), "line1\nline2\rline3".into());
        let output = format_github_actions(&secrets);
        let lines: Vec<&str> = output.lines().collect();
        assert_eq!(lines[0], "::add-mask::line1%0Aline2%0Dline3");
        assert_eq!(lines[1], "MULTI=line1%0Aline2%0Dline3");
    }

    #[test]
    fn dangerous_injected_env_names_detected_case_insensitive() {
        assert!(is_dangerous_injected_env_name("NODE_OPTIONS"));
        assert!(is_dangerous_injected_env_name("node_options"));
        assert!(!is_dangerous_injected_env_name("API_KEY"));
    }

    #[test]
    fn apply_exec_environment_removes_sensitive_vars_and_injects_secrets() {
        let mut secrets = HashMap::new();
        secrets.insert("APP_TOKEN".into(), "value-123".into());
        let mut cmd = Command::new("placeholder");
        apply_exec_environment(&mut cmd, &secrets, &[]);

        let envs: HashMap<String, Option<String>> = cmd
            .get_envs()
            .map(|(key, value)| {
                (
                    key.to_string_lossy().into_owned(),
                    value.map(|item| item.to_string_lossy().into_owned()),
                )
            })
            .collect();

        assert_eq!(envs.get("APP_TOKEN"), Some(&Some("value-123".into())));
        for var in SENSITIVE_VARS {
            assert_eq!(
                envs.get(*var),
                Some(&None),
                "expected '{var}' to be removed from child environment"
            );
        }
    }

    #[test]
    fn apply_exec_environment_removes_extra_strip_vars_even_when_not_globally_sensitive() {
        let mut secrets = HashMap::new();
        secrets.insert("GH_TOKEN".into(), "vault-gh-token".into());
        let mut cmd = Command::new("placeholder");
        apply_exec_environment(
            &mut cmd,
            &secrets,
            &["DOCKER_PASSWORD".to_string(), "TWINE_PASSWORD".to_string()],
        );

        let envs: HashMap<String, Option<String>> = cmd
            .get_envs()
            .map(|(key, value)| {
                (
                    key.to_string_lossy().into_owned(),
                    value.map(|item| item.to_string_lossy().into_owned()),
                )
            })
            .collect();

        assert_eq!(envs.get("GH_TOKEN"), Some(&Some("vault-gh-token".into())));
        assert_eq!(envs.get("DOCKER_PASSWORD"), Some(&None));
        assert_eq!(envs.get("TWINE_PASSWORD"), Some(&None));
    }

    #[test]
    fn parse_dotenv_all_forms() {
        let mut f = NamedTempFile::new().unwrap();
        writeln!(f, "# comment").unwrap();
        writeln!(f, "K1=plain").unwrap();
        writeln!(f, "K2=\"double\"").unwrap();
        writeln!(f, "K3='single'").unwrap();
        writeln!(f, "K4 = spaced ").unwrap();
        writeln!(f, "export K5=bash").unwrap();
        writeln!(f, "$K6 = powershell").unwrap();
        writeln!(f, "SECTION_HEADER").unwrap(); // no = → silently skipped
        writeln!(f).unwrap(); // blank line
        let m = parse_dotenv(f.path()).unwrap();
        assert_eq!(m["K1"], "plain");
        assert_eq!(m["K2"], "double");
        assert_eq!(m["K3"], "single");
        assert_eq!(m["K4"], "spaced");
        assert_eq!(m["K5"], "bash");
        assert_eq!(m["K6"], "powershell");
        assert!(!m.contains_key("#"));
        assert!(!m.contains_key("SECTION_HEADER"));
    }

    #[test]
    fn parse_dotenv_no_eq_is_skipped() {
        // Lines without '=' are free-form notes; they must not cause an error.
        let mut f = NamedTempFile::new().unwrap();
        writeln!(f, "NOEQUALS").unwrap();
        writeln!(f, "KEY=value").unwrap();
        let m = parse_dotenv(f.path()).unwrap();
        assert_eq!(m["KEY"], "value");
        assert!(!m.contains_key("NOEQUALS"));
    }

    #[test]
    fn parse_dotenv_file_not_found() {
        let result = parse_dotenv(Path::new("/tmp/tsafe-nonexistent-9999.env"));
        assert!(matches!(result, Err(SafeError::ImportParse { .. })));
    }

    #[test]
    fn parse_dotenv_env_var_reference_is_resolved() {
        // Values like `client_id="$ARM_CLIENT_ID"` are common in .env files that
        // forward an already-exported variable under a different name.
        // The parser must resolve $VAR references from:
        //   a) the process environment, and
        //   b) other literal values in the same file (intra-file resolution).
        let mut f = NamedTempFile::new().unwrap();
        // a) process-environment reference
        writeln!(f, r#"K_BARE=$TSAFE_TEST_RESOLVE_VAR"#).unwrap();
        writeln!(f, r#"K_DOUBLE="$TSAFE_TEST_RESOLVE_VAR""#).unwrap();
        writeln!(f, r#"K_SINGLE='$TSAFE_TEST_RESOLVE_VAR'"#).unwrap();
        // b) intra-file reference: ARM_CLIENT_ID defined above, client_id references it
        writeln!(f, r#"export ARM_CLIENT_ID="4a71128e-real-uuid""#).unwrap();
        writeln!(f, r#"client_id="$ARM_CLIENT_ID""#).unwrap();
        // c) unresolvable reference kept as literal
        writeln!(f, r#"K_UNSET=$TSAFE_TEST_NO_SUCH_VAR_XYZ"#).unwrap();
        let m = temp_env::with_var("TSAFE_TEST_RESOLVE_VAR", Some("resolved-value-abc"), || {
            parse_dotenv(f.path()).unwrap()
        });
        assert_eq!(
            m["K_BARE"], "resolved-value-abc",
            "bare $VAR from env should resolve"
        );
        assert_eq!(
            m["K_DOUBLE"], "resolved-value-abc",
            "double-quoted $VAR from env should resolve"
        );
        assert_eq!(
            m["K_SINGLE"], "resolved-value-abc",
            "single-quoted $VAR from env should resolve"
        );
        assert_eq!(
            m["ARM_CLIENT_ID"], "4a71128e-real-uuid",
            "literal should be stored as-is"
        );
        assert_eq!(
            m["client_id"], "4a71128e-real-uuid",
            "$VAR intra-file reference should resolve"
        );
        assert_eq!(
            m["K_UNSET"], "$TSAFE_TEST_NO_SUCH_VAR_XYZ",
            "unset $VAR kept as literal"
        );
    }
}
