//! Bridge between CLI flags and the `mcptest-config` resolver.
//!
//! Why a dedicated module instead of inlining the calls in [`crate::cli`]?
//! Building the resolved variable map is the same logic for `run`, `doctor`,
//! and any future subcommand (eval, coverage, report) that needs to know
//! what `${VAR}` resolves to. Centralising it here means the precedence
//! order is configured exactly once.

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

use anyhow::{Context, Result};
use mcptest_config::resolve::{discover_dotenv_files, Resolved, Resolver};
use mcptest_config::source::{parse_var_flag, Source};

use crate::cli::GlobalArgs;

/// Build a [`Resolved`] map from the CLI flags plus the optional YAML
/// `variables:` block.
///
/// Sources are applied in any order; the resolver enforces precedence
/// internally via the [`Source`] enum ordering. The caller is responsible
/// for passing the current working directory so dotenv auto-discovery is
/// deterministic in tests.
pub fn build_resolved(
    global: &GlobalArgs,
    yaml_variables: Option<HashMap<String, String>>,
    cwd: &Path,
) -> Result<Resolved> {
    let mut resolver = Resolver::new();

    if let Some(vars) = yaml_variables {
        resolver = resolver.with_yaml_variables(vars);
    }

    if !global.no_env_file {
        for (source, path) in discover_dotenv_files(cwd) {
            resolver = resolver
                .with_env_file(source, &path)
                .with_context(|| format!("failed to load env file at {}", path.display()))?;
        }
        if let Some(path) = &global.env_file {
            resolver = resolver
                .with_env_file(Source::CliEnvFile(path.clone()), path)
                .with_context(|| format!("failed to load --env-file at {}", path.display()))?;
        }
        // Repeatable `--env-files` flag. Each entry layers in
        // at the same `CliEnvFile` precedence; later entries overwrite
        // earlier ones, which matches the precedence rule in the
        // ticket: "process env > .env.local > .env > YAML defaults".
        for path in &global.env_files {
            resolver = resolver
                .with_env_file(Source::CliEnvFile(path.clone()), path)
                .with_context(|| format!("failed to load --env-files at {}", path.display()))?;
        }
    }

    resolver = resolver.with_os_env();

    let mut cli_pairs = Vec::with_capacity(global.vars.len() + global.env_vars.len());
    for (raw_key, raw_value) in &global.vars {
        let recombined = format!("{raw_key}={raw_value}");
        let (k, v) = parse_var_flag(&recombined)
            .with_context(|| format!("invalid --var flag: {recombined}"))?;
        cli_pairs.push((k, v));
    }
    // `--env KEY=VALUE` layers at CliFlag precedence, same
    // tier as `--var`. The parser in cli.rs already validated the
    // shape so we forward the pair verbatim.
    for (k, v) in &global.env_vars {
        cli_pairs.push((k.clone(), v.clone()));
    }
    resolver = resolver.with_cli_vars(cli_pairs);

    Ok(resolver.build())
}

/// Export resolved variables that did not come from the OS environment into
/// the process environment.
///
/// The run path reads credentials and `${VAR}` references straight from
/// `std::env` (the agent provider's key lookup and the run interpolation
/// context both do). Without this bridge, values supplied through
/// `--env-file`, `--env-files`, `--env`, or an auto-discovered `.env` are
/// resolved into the map but never reach `std::env`, so an API key in a
/// dotenv file silently falls back to the stub provider. Entries whose
/// winning source is the OS environment are skipped: they are already
/// present with the same value, and skipping them keeps the documented
/// precedence (a real shell variable still wins over a dotenv entry).
///
/// Must run before any worker thread starts (the tokio runtime and server
/// subprocesses spawn later in the run path) so the `set_var` calls happen
/// while the process is single threaded.
pub fn apply_resolved_to_process_env(resolved: &Resolved) {
    for (name, entry) in &resolved.values {
        if matches!(entry.source, Source::OsEnv) {
            continue;
        }
        std::env::set_var(name, &entry.value);
    }
}

/// Print a single-line redacted summary of the resolved variable map.
///
/// Format: "Resolved N variables from sources: CliFlag (a, b), OsEnv (c, ...)".
/// When `show_secrets` is true the values still are not printed: only the
/// names are listed, the `--show-secrets` flag controls the doctor variable
/// dump (see [`print_doctor_report`]).
pub fn print_verbose_summary(resolved: &Resolved, _show_secrets: bool) {
    let count = resolved.values.len();
    let grouped = group_by_source(resolved);
    let mut parts: Vec<String> = Vec::with_capacity(grouped.len());
    for (source, names) in grouped {
        parts.push(format!(
            "{} ({})",
            short_source_label(&source),
            names.join(", ")
        ));
    }
    if parts.is_empty() {
        println!("Resolved {count} variables.");
    } else {
        println!(
            "Resolved {count} variables from sources: {}",
            parts.join(", ")
        );
    }
}

/// Print the `mcptest doctor` report body.
///
/// Lists discovered dotenv files (and whether `--no-env-file` skipped them),
/// then prints a per-source variable count. Variable values are redacted
/// unless `--show-secrets` is set.
pub fn print_doctor_report(global: &GlobalArgs, cwd: &Path) -> Result<()> {
    if global.no_env_file {
        println!("Env file auto-discovery: disabled (--no-env-file)");
    } else {
        let discovered = discover_dotenv_files(cwd);
        if discovered.is_empty() {
            println!(
                "Env file auto-discovery: no .env files found in {}",
                cwd.display()
            );
        } else {
            println!("Env file auto-discovery (in load order):");
            for (source, path) in &discovered {
                println!("  - {} ({})", path.display(), short_source_label(source));
            }
        }
        if let Some(path) = &global.env_file {
            println!("Operator-pointed env file (--env-file): {}", path.display());
        }
    }

    let resolved = build_resolved(global, None, cwd)?;
    let grouped = group_by_source(&resolved);
    println!("Resolved {} variables.", resolved.values.len());
    for (source, names) in &grouped {
        println!("  {}: {}", short_source_label(source), names.len());
    }

    if global.show_secrets {
        println!("Variable values (UNSAFE, --show-secrets is set):");
        let mut entries: Vec<(&String, &mcptest_config::resolve::ResolvedValue)> =
            resolved.values.iter().collect();
        entries.sort_by(|a, b| a.0.cmp(b.0));
        for (name, entry) in entries {
            println!("  {name}={}", entry.value);
        }
    } else {
        let mut names: Vec<&String> = resolved.values.keys().collect();
        names.sort();
        for name in names {
            if let Some(entry) = resolved.entry(name) {
                println!(
                    "  {name}={} (from {})",
                    redact(&entry.value),
                    short_source_label(&entry.source)
                );
            }
        }
    }

    Ok(())
}

/// Redact a value for human-facing output.
///
/// Delegates to the central [`mcptest_core::redaction::Redactor`] library
/// so every surface shares one policy. When the central library
/// recognises the value as a secret (token prefix, hex blob, high-entropy
/// blob) it returns a `[REDACTED:<hint>]` token. For values the library
/// does not recognise we fall back to the legacy short-form mask so a
/// developer can still tell two distinct unknowns apart in a doctor dump.
pub fn redact(value: &str) -> String {
    let redactor = mcptest_core::redaction::Redactor::new();
    let redacted = redactor.redact_value(value);
    if redacted != value {
        return redacted.into_owned();
    }
    if value.len() < 8 {
        "***".to_string()
    } else {
        let prefix: String = value.chars().take(4).collect();
        format!("{prefix}***")
    }
}

/// Group resolved entries by source, returning a stable order suitable for
/// printing.
fn group_by_source(resolved: &Resolved) -> Vec<(Source, Vec<String>)> {
    let mut buckets: HashMap<Source, Vec<String>> = HashMap::new();
    for (name, entry) in &resolved.values {
        buckets
            .entry(entry.source.clone())
            .or_default()
            .push(name.clone());
    }
    let mut out: Vec<(Source, Vec<String>)> = buckets.into_iter().collect();
    for (_, names) in out.iter_mut() {
        names.sort();
    }
    out.sort_by(|a, b| a.0.cmp(&b.0));
    out
}

/// Return a short, human-friendly label for a [`Source`] variant.
fn short_source_label(source: &Source) -> String {
    match source {
        Source::CliFlag => "CliFlag".to_string(),
        Source::CliEnvFile(path) => format!("CliEnvFile({})", path.display()),
        Source::OsEnv => "OsEnv".to_string(),
        Source::EnvLocal => "EnvLocal".to_string(),
        Source::EnvTest => "EnvTest".to_string(),
        Source::EnvDefault => "EnvDefault".to_string(),
        Source::YamlVariables => "YamlVariables".to_string(),
        // `Source` is marked `#[non_exhaustive]`. Any future variant will
        // show up here as "Other" until we add an explicit arm.
        _ => "Other".to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;
    use tempfile::tempdir;

    fn empty_globals() -> GlobalArgs {
        GlobalArgs::default()
    }

    #[test]
    fn redacts_short_value_to_stars() {
        assert_eq!(redact(""), "***");
        assert_eq!(redact("abc"), "***");
        assert_eq!(redact("seven77"), "***");
    }

    #[test]
    fn redacts_long_value_to_prefix_and_stars() {
        assert_eq!(redact("abcdefgh"), "abcd***");
        assert_eq!(redact("supersecretvalue"), "supe***");
    }

    #[test]
    fn build_resolved_applies_cli_var_at_highest_precedence() {
        let dir = tempdir().unwrap();
        std::fs::write(dir.path().join(".env"), "FOO=from-env\n").unwrap();

        let mut globals = empty_globals();
        globals.vars = vec![("FOO".into(), "from-cli".into())];

        let resolved = build_resolved(&globals, None, dir.path()).unwrap();
        assert_eq!(resolved.get("FOO"), Some("from-cli"));
    }

    #[test]
    fn build_resolved_skips_dotenv_when_no_env_file_set() {
        let dir = tempdir().unwrap();
        std::fs::write(dir.path().join(".env"), "ONLY_IN_ENV=present\n").unwrap();

        let mut globals = empty_globals();
        globals.no_env_file = true;

        let resolved = build_resolved(&globals, None, dir.path()).unwrap();
        assert!(resolved.get("ONLY_IN_ENV").is_none());
    }

    #[test]
    fn build_resolved_loads_explicit_env_file() {
        let dir = tempdir().unwrap();
        let path: PathBuf = dir.path().join("custom.env");
        std::fs::write(&path, "CUSTOM=yes\n").unwrap();

        let mut globals = empty_globals();
        globals.env_file = Some(path);

        let resolved = build_resolved(&globals, None, dir.path()).unwrap();
        assert_eq!(resolved.get("CUSTOM"), Some("yes"));
    }

    #[test]
    fn build_resolved_layers_yaml_variables_at_lowest_precedence() {
        let dir = tempdir().unwrap();
        let mut yaml = HashMap::new();
        yaml.insert("YAML_ONLY".to_string(), "yes".to_string());
        yaml.insert("YAML_OVERRIDDEN".to_string(), "from-yaml".to_string());

        let mut globals = empty_globals();
        globals.vars = vec![("YAML_OVERRIDDEN".into(), "from-cli".into())];

        let resolved = build_resolved(&globals, Some(yaml), dir.path()).unwrap();
        assert_eq!(resolved.get("YAML_ONLY"), Some("yes"));
        assert_eq!(resolved.get("YAML_OVERRIDDEN"), Some("from-cli"));
    }

    #[test]
    fn apply_resolved_exports_env_file_value_to_process_env() {
        use mcptest_config::resolve::ResolvedValue;
        // A value whose winning source is a dotenv file must reach `std::env`
        // so the agent provider's key lookup and run interpolation can see it.
        // The unique name keeps this isolated from other parallel tests.
        let name = "MCPTEST_APPLY_RESOLVED_ENVFILE_KEY";
        let mut resolved = Resolved::new();
        resolved.values.insert(
            name.to_string(),
            ResolvedValue {
                value: "from-dotenv".to_string(),
                source: Source::EnvDefault,
            },
        );

        apply_resolved_to_process_env(&resolved);
        assert_eq!(std::env::var(name).as_deref(), Ok("from-dotenv"));
        std::env::remove_var(name);
    }

    #[test]
    fn apply_resolved_skips_os_env_winners() {
        use mcptest_config::resolve::ResolvedValue;
        // Entries already won by the OS environment are left untouched: the
        // value is already present, and skipping the `set_var` keeps the
        // documented precedence (a real shell var still beats a dotenv entry).
        let name = "MCPTEST_APPLY_RESOLVED_OSENV_KEY";
        std::env::set_var(name, "from-shell");
        let mut resolved = Resolved::new();
        resolved.values.insert(
            name.to_string(),
            ResolvedValue {
                value: "from-shell".to_string(),
                source: Source::OsEnv,
            },
        );

        apply_resolved_to_process_env(&resolved);
        assert_eq!(std::env::var(name).as_deref(), Ok("from-shell"));
        std::env::remove_var(name);
    }
}
