use crate::cmd::{RunningPidGuard, prepare_noninteractive_child};
use crate::config::Settings;
use crate::env;
use serde::Deserialize;
use serde_yaml::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::LazyLock as Lazy;

use crate::file::path_env_without_shims;

/// Cache for tokens obtained from `credential_command`.
/// Key format is `{provider}:{host}` to avoid cross-provider collisions and
/// preserve host-aware lookups (so different GitHub Enterprise instances
/// still spawn their own helper). Callers are responsible for not walking
/// equivalent hosts (e.g. `github.com` and `api.github.com`) on this path.
static CREDENTIAL_COMMAND_CACHE: Lazy<std::sync::Mutex<HashMap<String, Option<String>>>> =
    Lazy::new(Default::default);

/// Cache for tokens obtained from `git credential fill`.
/// Key format is `{provider}:{host}` to avoid cross-provider collisions.
static GIT_CREDENTIAL_CACHE: Lazy<std::sync::Mutex<HashMap<String, Option<String>>>> =
    Lazy::new(Default::default);

#[derive(Deserialize)]
struct HostTokensFile {
    tokens: Option<HashMap<String, HostTokenEntry>>,
}

#[derive(Deserialize)]
struct HostTokenEntry {
    token: Option<String>,
}

pub fn parse_tokens_toml(contents: &str) -> Option<HashMap<String, String>> {
    let file: HostTokensFile = toml::from_str(contents).ok()?;
    Some(
        file.tokens?
            .into_iter()
            .filter_map(|(host, entry)| entry.token.map(|token| (host, token)))
            .collect(),
    )
}

pub fn read_tokens_toml(filename: &str, label: &str) -> Option<HashMap<String, String>> {
    let path = env::MISE_CONFIG_DIR.join(filename);
    let contents = match std::fs::read_to_string(&path) {
        Ok(c) => c,
        Err(e) => {
            trace!("{filename} not readable at {}: {e}", path.display());
            return None;
        }
    };
    match parse_tokens_toml(&contents) {
        Some(tokens) => Some(tokens),
        None => {
            debug!("failed to parse {label} at {}", path.display());
            None
        }
    }
}

/// Get a token by running a provider-specific `credential_command`.
///
/// The host and provider are passed through `MISE_CREDENTIAL_HOST` and
/// `MISE_CREDENTIAL_PROVIDER`. Results are cached per `{provider}:{host}`
/// for the lifetime of the process — repeated calls for the same host
/// reuse the cached value (positive or negative).
pub fn get_credential_command_token(provider: &str, cmd: &str, host: &str) -> Option<String> {
    if credential_command_uses_legacy_host_arg(cmd) {
        deprecated_at!(
            "2026.11.0",
            "2027.11.0",
            "credential-command-shell-arg",
            "Use MISE_CREDENTIAL_HOST instead of $1/${{1}} in {provider} credential_command"
        );
    }

    let cache_key = format!("{provider}:{host}");
    let mut cache = CREDENTIAL_COMMAND_CACHE
        .lock()
        .expect("CREDENTIAL_COMMAND_CACHE mutex poisoned");
    if let Some(token) = cache.get(&cache_key) {
        return token.clone();
    }

    let path_without_shims = path_env_without_shims();
    let (program, args) = match credential_command_shell(cmd, host) {
        Some(command) => command,
        None => {
            debug!("{provider} credential_command skipped: default inline shell is empty");
            cache.insert(cache_key, None);
            return None;
        }
    };
    let mut command = std::process::Command::new(&program);
    command.args(&args);
    // On Windows, route a `cmd /c <credential-command>` through the verbatim
    // builder so inner double quotes survive (#9355). Other shells and Unix keep
    // the plain Command::new(program).args(args). The command body is the last
    // element of args (see credential_command_shell_from).
    #[cfg(windows)]
    if let Some((body, flags)) = args.split_last()
        && let Some(c) = crate::path::cmd_verbatim_command(&program, flags, body)
    {
        command = c;
    }
    let result = command
        .env("PATH", &path_without_shims)
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("MISE_CREDENTIAL_HOST", host)
        .env("MISE_CREDENTIAL_PROVIDER", provider)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .output()
        .ok()
        .and_then(|output| {
            if !output.status.success() {
                if let Ok(err) = String::from_utf8(output.stderr)
                    && !err.trim().is_empty()
                {
                    debug!("{provider} credential_command stderr: {}", err.trim());
                }
                return None;
            }
            String::from_utf8(output.stdout)
                .ok()
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
        });

    trace!(
        "{provider} credential_command for {host}: {}",
        if result.is_some() {
            "found"
        } else {
            "not found"
        }
    );
    cache.insert(cache_key, result.clone());
    result
}

fn credential_command_shell(cmd: &str, host: &str) -> Option<(String, Vec<String>)> {
    let shell = match Settings::get().default_inline_shell() {
        Ok(shell) => shell,
        Err(e) => {
            debug!("failed to parse default inline shell for credential_command: {e}");
            return None;
        }
    };
    credential_command_shell_from(&shell, cmd, host)
}

fn credential_command_shell_from(
    shell: &[String],
    cmd: &str,
    host: &str,
) -> Option<(String, Vec<String>)> {
    let (program, shell_args) = shell.split_first()?;
    let mut args = shell_args.to_vec();
    args.push(cmd.to_string());
    if shell_supports_posix_c_arg_passing(program) {
        args.push("mise-credential-helper".to_string()); // $0
        args.push(host.to_string()); // deprecated $1 compatibility
    }
    Some((program.to_string(), args))
}

fn shell_supports_posix_c_arg_passing(program: &str) -> bool {
    const SHELLS: &[&str] = &["ash", "bash", "dash", "ksh", "sh", "zsh"];
    let basename = program.rsplit(['/', '\\']).next().unwrap_or(program);
    let stem = match basename.rsplit_once('.') {
        Some((stem, ext)) if ext.eq_ignore_ascii_case("exe") => stem,
        _ => basename,
    };
    SHELLS.iter().any(|shell| stem.eq_ignore_ascii_case(shell))
}

fn credential_command_uses_legacy_host_arg(cmd: &str) -> bool {
    cmd.contains("$1") || cmd.contains("${1}")
}

/// Get a token by running `git credential fill`.
///
/// Results are cached per provider+host so the subprocess is only spawned once.
pub fn get_git_credential_token(provider: &str, host: &str) -> Option<String> {
    let cache_key = format!("{provider}:{host}");
    let mut cache = GIT_CREDENTIAL_CACHE
        .lock()
        .expect("GIT_CREDENTIAL_CACHE mutex poisoned");
    if let Some(token) = cache.get(&cache_key) {
        return token.clone();
    }

    let path_without_shims = path_env_without_shims();
    let input = format!("protocol=https\nhost={host}\n\n");
    let mut command = std::process::Command::new("git");
    command
        .args(["credential", "fill"])
        .env("PATH", &path_without_shims)
        .env("GIT_TERMINAL_PROMPT", "0")
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null());
    prepare_noninteractive_child(&mut command);
    let result = command.spawn().ok().and_then(|mut child| {
        let _running_pid = RunningPidGuard::new(Some(child.id()));
        use std::io::Write;
        child.stdin.take()?.write_all(input.as_bytes()).ok()?;
        let output = child.wait_with_output().ok()?;
        if !output.status.success() {
            return None;
        }
        String::from_utf8(output.stdout)
            .ok()?
            .lines()
            .find_map(|line| line.strip_prefix("password="))
            .map(|p| p.to_string())
            .filter(|s| !s.is_empty())
    });

    trace!(
        "{provider} git credential fill for {host}: {}",
        if result.is_some() {
            "found"
        } else {
            "not found"
        }
    );
    cache.insert(cache_key, result.clone());
    result
}

pub fn mask_token(token: &str) -> String {
    let len = token.chars().count();
    if len <= 4 {
        "*".repeat(len)
    } else if len <= 8 {
        let prefix: String = token.chars().take(4).collect();
        format!("{prefix}…")
    } else {
        let prefix: String = token.chars().take(4).collect();
        let suffix: String = token.chars().skip(len - 4).collect();
        format!("{prefix}…{suffix}")
    }
}

pub fn yaml_hosts_to_tokens(contents: &str) -> Option<HashMap<String, String>> {
    let yaml: Value = serde_yaml::from_str(contents).ok()?;
    let mut out = HashMap::new();
    if let Some(map) = yaml.as_mapping() {
        collect_mapping_tokens(map, &mut out);

        if let Some(hosts_value) = map.get(Value::String("hosts".to_string()))
            && let Some(hosts) = hosts_value.as_mapping()
        {
            collect_mapping_tokens(hosts, &mut out);
        }

        if let Some(logins_value) = map.get(Value::String("logins".to_string())) {
            collect_list_tokens(logins_value, &mut out);
        }
    }
    if out.is_empty() { None } else { Some(out) }
}

fn collect_mapping_tokens(map: &serde_yaml::Mapping, out: &mut HashMap<String, String>) {
    for (k, v) in map {
        let Some(host) = k.as_str() else {
            continue;
        };
        let Some(entry) = v.as_mapping() else {
            continue;
        };

        if let Some(token) = token_from_entry(entry) {
            out.insert(host.to_string(), token);
        }
    }
}

fn collect_list_tokens(v: &Value, out: &mut HashMap<String, String>) {
    let Some(entries) = v.as_sequence() else {
        return;
    };
    for entry in entries {
        let Some(map) = entry.as_mapping() else {
            continue;
        };
        let host = map
            .get(Value::String("name".to_string()))
            .and_then(Value::as_str)
            .or_else(|| {
                map.get(Value::String("host".to_string()))
                    .and_then(Value::as_str)
            })
            .or_else(|| {
                map.get(Value::String("url".to_string()))
                    .and_then(Value::as_str)
            });
        if let (Some(host), Some(token)) = (host, token_from_entry(map)) {
            out.insert(host.to_string(), token);
        }
    }
}

fn token_from_entry(entry: &serde_yaml::Mapping) -> Option<String> {
    ["oauth_token", "token", "access_token", "access-token"]
        .iter()
        .find_map(|k| {
            entry
                .get(Value::String((*k).to_string()))
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|s| !s.is_empty())
                .map(ToString::to_string)
        })
}

/// First candidate that is a regular file, else `fallback`.
///
/// Forge CLIs (`gh`, `glab`) each keep their config under a platform-dependent directory, and
/// mise probes the plausible ones in priority order. Factored out so that order is one rule with
/// one set of tests instead of a copy per forge — the copies are exactly how the Windows location
/// came to be missing from both.
///
/// `is_file` rather than `exists`: every caller hands the result to `read_to_string`, so a
/// directory that happens to be named `hosts.yml` must not shadow a real config further down the
/// list. Symlinks are followed, so a symlinked config still matches.
pub fn first_existing_file(candidates: Vec<PathBuf>, fallback: PathBuf) -> PathBuf {
    candidates
        .into_iter()
        .find(|p| p.is_file())
        .unwrap_or(fallback)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_first_existing_file_prefers_the_earliest_that_exists() {
        let dir = tempfile::tempdir().unwrap();
        let second = dir.path().join("second");
        let third = dir.path().join("third");
        std::fs::write(&second, "").unwrap();
        std::fs::write(&third, "").unwrap();
        assert_eq!(
            first_existing_file(
                vec![dir.path().join("first"), second.clone(), third],
                dir.path().join("fallback")
            ),
            second,
            "a missing earlier candidate must be skipped, not short-circuit the search"
        );
    }

    #[test]
    fn test_first_existing_file_skips_a_directory_of_the_same_name() {
        // A directory named like the config file must not shadow a real one further down:
        // the result is handed straight to `read_to_string`.
        let dir = tempfile::tempdir().unwrap();
        let decoy = dir.path().join("early").join("config.yml");
        std::fs::create_dir_all(&decoy).unwrap();
        let real = dir.path().join("late-config.yml");
        std::fs::write(&real, "").unwrap();
        assert_eq!(
            first_existing_file(vec![decoy, real.clone()], dir.path().join("fallback")),
            real
        );
    }

    #[test]
    fn test_first_existing_file_falls_back_when_none_exist() {
        let dir = tempfile::tempdir().unwrap();
        let fallback = dir.path().join("fallback");
        assert_eq!(
            first_existing_file(
                vec![dir.path().join("a"), dir.path().join("b")],
                fallback.clone()
            ),
            fallback,
            "the fallback is returned even though it does not exist -- callers use it to name \
             the conventional location in a trace message"
        );
    }

    #[test]
    fn test_first_existing_file_with_no_candidates() {
        let dir = tempfile::tempdir().unwrap();
        let fallback = dir.path().join("fallback");
        assert_eq!(first_existing_file(vec![], fallback.clone()), fallback);
    }

    #[test]
    fn test_parse_tokens_toml() {
        let toml = r#"
[tokens."example.com"]
token = "abc"
"#;
        let result = parse_tokens_toml(toml).unwrap();
        assert_eq!(result.get("example.com").unwrap(), "abc");
    }

    #[test]
    fn test_yaml_hosts_to_tokens_with_hosts_map() {
        let yaml = r#"
hosts:
  gitlab.com:
    token: glab-token
  codeberg.org:
    oauth_token: tea-token
"#;
        let result = yaml_hosts_to_tokens(yaml).unwrap();
        assert_eq!(result.get("gitlab.com").unwrap(), "glab-token");
        assert_eq!(result.get("codeberg.org").unwrap(), "tea-token");
    }

    #[test]
    fn test_yaml_hosts_to_tokens_with_logins_list() {
        let yaml = r#"
logins:
  - name: codeberg.org
    token: token1
  - host: forgejo.local
    access_token: token2
"#;
        let result = yaml_hosts_to_tokens(yaml).unwrap();
        assert_eq!(result.get("codeberg.org").unwrap(), "token1");
        assert_eq!(result.get("forgejo.local").unwrap(), "token2");
    }

    #[test]
    fn test_credential_command_shell_preserves_sh_host_arg() {
        let shell = shell_words::split("sh -c -o errexit").unwrap();
        let (program, args) =
            credential_command_shell_from(&shell, "echo token-for-$1", "ghe.example.com").unwrap();

        assert_eq!(program, "sh");
        assert_eq!(
            args,
            vec![
                "-c",
                "-o",
                "errexit",
                "echo token-for-$1",
                "mise-credential-helper",
                "ghe.example.com"
            ]
        );
    }

    #[test]
    fn test_credential_command_shell_does_not_append_args_for_cmd() {
        let shell = shell_words::split("cmd /c").unwrap();
        let (program, args) =
            credential_command_shell_from(&shell, "echo %MISE_CREDENTIAL_HOST%", "github.com")
                .unwrap();

        assert_eq!(program, "cmd");
        assert_eq!(args, vec!["/c", "echo %MISE_CREDENTIAL_HOST%"]);
    }

    #[test]
    fn test_shell_supports_posix_c_arg_passing_matches_windows_bash_path() {
        assert!(shell_supports_posix_c_arg_passing("ash"));
        assert!(shell_supports_posix_c_arg_passing(
            r"C:\Program Files\Git\bin\BASH.EXE"
        ));
        assert!(!shell_supports_posix_c_arg_passing("cmd.exe"));
    }

    #[cfg(unix)]
    #[test]
    fn test_get_credential_command_token_caches_per_host() {
        // Same host on repeat calls must reuse the cached result (1 spawn).
        // Different hosts get their own cache entries — important for
        // multi-instance setups (e.g. github.com vs a GHE instance).
        let dir = tempfile::tempdir().unwrap();
        let counter = dir.path().join("counter");
        let cmd = format!(
            "echo invocation >> {} && echo \"token-for-$MISE_CREDENTIAL_HOST\"",
            counter.display()
        );
        let provider = "test-per-host";

        let a1 = get_credential_command_token(provider, &cmd, "host-a.example.com").unwrap();
        let a2 = get_credential_command_token(provider, &cmd, "host-a.example.com").unwrap();
        let b1 = get_credential_command_token(provider, &cmd, "host-b.example.com").unwrap();

        assert_eq!(a1, "token-for-host-a.example.com");
        assert_eq!(a2, "token-for-host-a.example.com");
        assert_eq!(b1, "token-for-host-b.example.com");

        let invocations = std::fs::read_to_string(&counter).unwrap().lines().count();
        assert_eq!(
            invocations, 2,
            "1 spawn per host: 1 for host-a, 1 for host-b"
        );
    }

    #[test]
    fn test_credential_command_uses_legacy_host_arg() {
        assert!(credential_command_uses_legacy_host_arg("echo token-for-$1"));
        assert!(credential_command_uses_legacy_host_arg(
            "echo token-for-${1}"
        ));
        assert!(!credential_command_uses_legacy_host_arg(
            "echo $MISE_CREDENTIAL_HOST"
        ));
    }
}
