use crate::config::{
    Config,
    env_directive::{EnvDirectiveContext, EnvResults},
};
use crate::env_diff::EnvMap as TeraEnvMap;
use crate::file::display_path;
use crate::{Result, file, sops};
use eyre::{WrapErr, bail, eyre};
use indexmap::IndexMap;
use rops::file::format::{JsonFileFormat, TomlFileFormat, YamlFileFormat};
use std::{
    path::{Path, PathBuf},
    sync::Arc,
};

// use indexmap so source is after value for `mise env --json` output
type EnvMap = IndexMap<String, String>;

#[derive(serde::Serialize, serde::Deserialize)]
struct Env<V> {
    #[serde(default = "IndexMap::new")]
    sops: IndexMap<String, V>,
    #[serde(flatten)]
    env: IndexMap<String, V>,
}

impl EnvResults {
    pub(super) async fn file(
        ctx: &mut EnvDirectiveContext<'_>,
        input: String,
        expand: bool,
    ) -> Result<IndexMap<PathBuf, EnvMap>> {
        let mut out = IndexMap::new();
        let s = ctx.parse_template(&input)?;
        let expand = expand && crate::config::Settings::get().env_shell_expand;
        // Accumulate loaded vars so opted-in expansion can reference values from
        // an earlier file in the same directive or an earlier env block.
        let mut acc: TeraEnvMap = ctx.exec_env.clone();
        for p in xx::file::glob(ctx.normalize_path(s.into())).unwrap_or_default() {
            let config = ctx.config;
            let exec_env = ctx.exec_env;
            let parse_template = |s: String| ctx.parse_template(&s);
            let ext = p
                .extension()
                .map(|e| e.to_string_lossy().to_string())
                .unwrap_or_default();
            let mut loaded = match ext.as_str() {
                "json" => Self::json(config, exec_env, &p, parse_template).await?,
                "yaml" => Self::yaml(config, exec_env, &p, parse_template).await?,
                "toml" => Self::toml(config, exec_env, &p, parse_template).await?,
                _ => Self::dotenv(&p, &acc, expand).await?,
            };
            // Structured files are literal by default. With `expand = true`, run
            // their values through the same `$VAR` engine used by `[env]` values
            // and accumulate key-by-key for same-file references.
            if expand && matches!(ext.as_str(), "json" | "yaml" | "toml") {
                for (k, v) in loaded.iter_mut() {
                    let mut missing = Vec::new();
                    let expanded = super::shell_expand_env(&*v, &acc, &mut missing);
                    for var in missing {
                        warn_once!(
                            "env var '{var}' is not defined and will be left unexpanded. \
                             Use ${{{var}:-}} to default to an empty string and suppress \
                             this warning."
                        );
                    }
                    *v = expanded;
                    acc.insert(k.clone(), v.clone());
                }
            } else {
                for (k, v) in &loaded {
                    acc.insert(k.clone(), v.clone());
                }
            }
            out.insert(p, loaded);
        }
        Ok(out)
    }

    async fn json<PT>(
        config: &Arc<Config>,
        exec_env: &TeraEnvMap,
        p: &Path,
        parse_template: PT,
    ) -> Result<EnvMap>
    where
        PT: FnMut(String) -> Result<String>,
    {
        let errfn = || eyre!("failed to parse json file: {}", display_path(p));
        if let Ok(raw) = file::read_to_string(p) {
            let mut f: Env<serde_json::Value> = serde_json::from_str(&raw).wrap_err_with(errfn)?;
            if !f.sops.is_empty() {
                let decrypted = sops::decrypt::<_, JsonFileFormat>(
                    config,
                    exec_env,
                    &raw,
                    parse_template,
                    "json",
                )
                .await?;
                if !decrypted.is_empty() {
                    f = serde_json::from_str(&decrypted).wrap_err_with(errfn)?;
                } else {
                    return Ok(EnvMap::new());
                }
            }
            f.env
                .into_iter()
                .map(|(k, v)| {
                    Ok((
                        k,
                        match v {
                            serde_json::Value::String(s) => s,
                            serde_json::Value::Number(n) => n.to_string(),
                            serde_json::Value::Bool(b) => b.to_string(),
                            _ => bail!("unsupported json value: {v:?}"),
                        },
                    ))
                })
                .collect()
        } else {
            Ok(EnvMap::new())
        }
    }

    async fn yaml<PT>(
        config: &Arc<Config>,
        exec_env: &TeraEnvMap,
        p: &Path,
        parse_template: PT,
    ) -> Result<EnvMap>
    where
        PT: FnMut(String) -> Result<String>,
    {
        let errfn = || eyre!("failed to parse yaml file: {}", display_path(p));
        if let Ok(raw) = file::read_to_string(p) {
            let mut f: Env<serde_yaml::Value> = serde_yaml::from_str(&raw).wrap_err_with(errfn)?;
            if !f.sops.is_empty() {
                let decrypted = sops::decrypt::<_, YamlFileFormat>(
                    config,
                    exec_env,
                    &raw,
                    parse_template,
                    "yaml",
                )
                .await?;
                if !decrypted.is_empty() {
                    f = serde_yaml::from_str(&decrypted).wrap_err_with(errfn)?;
                } else {
                    return Ok(EnvMap::new());
                }
            }
            f.env
                .into_iter()
                .map(|(k, v)| {
                    Ok((
                        k,
                        match v {
                            serde_yaml::Value::String(s) => s,
                            serde_yaml::Value::Number(n) => n.to_string(),
                            serde_yaml::Value::Bool(b) => b.to_string(),
                            _ => bail!("unsupported yaml value: {v:?}"),
                        },
                    ))
                })
                .collect()
        } else {
            Ok(EnvMap::new())
        }
    }

    async fn toml<PT>(
        config: &Arc<Config>,
        exec_env: &TeraEnvMap,
        p: &Path,
        parse_template: PT,
    ) -> Result<EnvMap>
    where
        PT: FnMut(String) -> Result<String>,
    {
        let errfn = || eyre!("failed to parse toml file: {}", display_path(p));
        if let Ok(raw) = file::read_to_string(p) {
            let mut f: Env<toml::Value> = toml::from_str(&raw).wrap_err_with(errfn)?;
            if !f.sops.is_empty() {
                let decrypted = sops::decrypt::<_, TomlFileFormat>(
                    config,
                    exec_env,
                    &raw,
                    parse_template,
                    "toml",
                )
                .await?;
                if !decrypted.is_empty() {
                    f = toml::from_str(&decrypted).wrap_err_with(errfn)?;
                } else {
                    return Ok(EnvMap::new());
                }
            }
            f.env
                .into_iter()
                .map(|(k, v)| {
                    Ok((
                        k,
                        match v {
                            toml::Value::String(s) => s,
                            toml::Value::Integer(n) => n.to_string(),
                            toml::Value::Boolean(b) => b.to_string(),
                            _ => bail!("unsupported toml value: {v:?}"),
                        },
                    ))
                })
                .collect()
        } else {
            Ok(EnvMap::new())
        }
    }

    async fn dotenv(p: &Path, acc: &TeraEnvMap, expand: bool) -> Result<EnvMap> {
        let errfn = || eyre!("failed to parse dotenv file: {}", display_path(p));
        if !expand {
            // Preserve dotenvy's normal behavior unless cross-file expansion was
            // explicitly requested.
            let mut env = EnvMap::new();
            if let Ok(dotenv) = dotenvy::from_path_iter(p) {
                for item in dotenv {
                    let (k, v) = item.wrap_err_with(errfn)?;
                    env.insert(k, v);
                }
            }
            return Ok(env);
        }
        // dotenvy substitutes `${VAR}` only against the process env + vars defined
        // earlier in the same file and has no API for a custom map. Seed the parse
        // with accumulated values, then retain only keys defined by this file.
        let Ok(content) = file::read_to_string(p) else {
            return Ok(EnvMap::new());
        };
        let mut own_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
        for item in dotenvy::from_read_iter(content.as_bytes()) {
            let (k, _v) = item.wrap_err_with(errfn)?;
            own_keys.insert(k);
        }
        if own_keys.is_empty() {
            return Ok(EnvMap::new());
        }
        let mut prefix = String::new();
        for (k, v) in acc {
            if own_keys.contains(k) || !is_env_key(k) {
                continue;
            }
            prefix.push_str(k);
            prefix.push_str("=\"");
            prefix.push_str(&escape_dotenv_double_quoted(v));
            prefix.push_str("\"\n");
        }
        let augmented = format!("{prefix}{content}");
        let mut env = EnvMap::new();
        for item in dotenvy::from_read_iter(augmented.as_bytes()) {
            let (k, v) = item.wrap_err_with(errfn)?;
            if own_keys.contains(&k) {
                env.insert(k, v);
            }
        }
        Ok(env)
    }
}

fn is_env_key(k: &str) -> bool {
    let mut chars = k.chars();
    chars
        .next()
        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}

fn escape_dotenv_double_quoted(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '$' => out.push_str("\\$"),
            '\n' => out.push_str("\\n"),
            _ => out.push(c),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::Settings;
    use rops::{
        cryptography::{cipher::AES256GCM, hasher::SHA512},
        file::builder::RopsFileBuilder,
        integration::{AgeIntegration, Integration},
    };

    const AGE_PUBLIC_KEY: &str = "age1se5ghfycr4n8kcwc3qwf234ymvmr2lex2a99wh8gpfx97glwt9hqch4569";
    const AGE_PRIVATE_KEY: &str =
        "AGE-SECRET-KEY-1EQUCGFZH8UZKSZ0Z5N5T234YRNDT4U9H7QNYXWRRNJYDDVXE6FWSCPGNJ7";
    const UNRELATED_AGE_PRIVATE_KEY: &str =
        "AGE-SECRET-KEY-1W92VNVAX0YKJX4WQ6SV7T7X2PZYUC0STF5TKJLQ9ZUWM62HLMN3QYQZJ6F";
    static ENV_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());

    fn encrypted_toml() -> String {
        RopsFileBuilder::<TomlFileFormat>::new(r#"SECRET = "mysecret""#)
            .unwrap()
            .add_integration_key::<AgeIntegration>(
                AgeIntegration::parse_key_id(AGE_PUBLIC_KEY).unwrap(),
            )
            .encrypt::<AES256GCM, SHA512>()
            .unwrap()
            .to_string()
    }

    fn restore_env_var(key: &str, prev: Option<String>) {
        match prev {
            Some(v) => crate::env::set_var(key, v),
            None => crate::env::remove_var(key),
        }
    }

    #[tokio::test]
    async fn decrypts_sops_toml_file() {
        let _lock = ENV_MUTEX.lock().await;
        let prev_age_key = crate::env::var("MISE_SOPS_AGE_KEY").ok();
        let prev_rops = crate::env::var("MISE_SOPS_ROPS").ok();
        crate::env::remove_var("MISE_SOPS_ROPS");
        crate::env::set_var("MISE_SOPS_AGE_KEY", AGE_PRIVATE_KEY);
        Settings::reset(None);
        let config = Config::reset().await.unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let p = tmp.path().join(".env.toml");

        file::write(&p, encrypted_toml()).unwrap();

        let exec_env = TeraEnvMap::new();
        let env = EnvResults::toml(&config, &exec_env, &p, Ok).await.unwrap();
        assert_eq!(env.get("SECRET").unwrap(), "mysecret");

        restore_env_var("MISE_SOPS_AGE_KEY", prev_age_key);
        restore_env_var("MISE_SOPS_ROPS", prev_rops);
        Settings::reset(None);
    }

    #[tokio::test]
    async fn decrypts_sops_toml_file_with_exec_env_mise_age_key_file() {
        let _lock = ENV_MUTEX.lock().await;
        let prev_age_key = crate::env::var("MISE_SOPS_AGE_KEY").ok();
        let prev_age_key_file = crate::env::var("MISE_SOPS_AGE_KEY_FILE").ok();
        let prev_rops = crate::env::var("MISE_SOPS_ROPS").ok();
        crate::env::remove_var("MISE_SOPS_AGE_KEY");
        crate::env::remove_var("MISE_SOPS_AGE_KEY_FILE");
        crate::env::remove_var("MISE_SOPS_ROPS");
        Settings::reset(None);
        let config = Config::reset().await.unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let p = tmp.path().join(".env.toml");
        let key_file = tmp.path().join("age.txt");
        file::write(&p, encrypted_toml()).unwrap();
        file::write(&key_file, AGE_PRIVATE_KEY).unwrap();

        let mut exec_env = TeraEnvMap::new();
        exec_env.insert(
            "MISE_SOPS_AGE_KEY_FILE".into(),
            key_file.to_string_lossy().to_string(),
        );
        let env = EnvResults::toml(&config, &exec_env, &p, Ok).await.unwrap();
        assert_eq!(env.get("SECRET").unwrap(), "mysecret");

        restore_env_var("MISE_SOPS_AGE_KEY", prev_age_key);
        restore_env_var("MISE_SOPS_AGE_KEY_FILE", prev_age_key_file);
        restore_env_var("MISE_SOPS_ROPS", prev_rops);
        Settings::reset(None);
    }

    #[tokio::test]
    async fn decrypts_sops_toml_file_with_multiple_age_keys() {
        let _lock = ENV_MUTEX.lock().await;
        let prev_age_key = crate::env::var("MISE_SOPS_AGE_KEY").ok();
        let prev_age_key_file = crate::env::var("MISE_SOPS_AGE_KEY_FILE").ok();
        let prev_rops = crate::env::var("MISE_SOPS_ROPS").ok();
        crate::env::remove_var("MISE_SOPS_AGE_KEY");
        crate::env::remove_var("MISE_SOPS_AGE_KEY_FILE");
        crate::env::remove_var("MISE_SOPS_ROPS");
        Settings::reset(None);
        let config = Config::reset().await.unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let p = tmp.path().join(".env.toml");
        let key_file = tmp.path().join("age.txt");
        file::write(&p, encrypted_toml()).unwrap();
        file::write(
            &key_file,
            format!(
                "# unrelated identity\r\n{UNRELATED_AGE_PRIVATE_KEY}\r\n\r\n# matching identity\r\n{AGE_PRIVATE_KEY}\r\n"
            ),
        )
        .unwrap();

        let mut exec_env = TeraEnvMap::new();
        exec_env.insert(
            "MISE_SOPS_AGE_KEY_FILE".into(),
            key_file.to_string_lossy().to_string(),
        );
        let env = EnvResults::toml(&config, &exec_env, &p, Ok).await.unwrap();
        assert_eq!(env.get("SECRET").unwrap(), "mysecret");

        restore_env_var("MISE_SOPS_AGE_KEY", prev_age_key);
        restore_env_var("MISE_SOPS_AGE_KEY_FILE", prev_age_key_file);
        restore_env_var("MISE_SOPS_ROPS", prev_rops);
        Settings::reset(None);
    }

    #[tokio::test]
    async fn rejects_invalid_non_comment_age_key_lines() {
        let _lock = ENV_MUTEX.lock().await;
        let prev_age_key = crate::env::var("MISE_SOPS_AGE_KEY").ok();
        let prev_age_key_file = crate::env::var("MISE_SOPS_AGE_KEY_FILE").ok();
        let prev_rops = crate::env::var("MISE_SOPS_ROPS").ok();
        crate::env::remove_var("MISE_SOPS_AGE_KEY");
        crate::env::remove_var("MISE_SOPS_AGE_KEY_FILE");
        crate::env::remove_var("MISE_SOPS_ROPS");
        Settings::reset(None);
        let config = Config::reset().await.unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let p = tmp.path().join(".env.toml");
        let key_file = tmp.path().join("age.txt");
        file::write(&p, encrypted_toml()).unwrap();
        file::write(&key_file, format!("not-an-age-key\n{AGE_PRIVATE_KEY}\n")).unwrap();

        let mut exec_env = TeraEnvMap::new();
        exec_env.insert(
            "MISE_SOPS_AGE_KEY_FILE".into(),
            key_file.to_string_lossy().to_string(),
        );
        let err = EnvResults::toml(&config, &exec_env, &p, Ok)
            .await
            .unwrap_err();
        assert!(
            err.to_string().contains("failed to decrypt sops file"),
            "{err}"
        );

        restore_env_var("MISE_SOPS_AGE_KEY", prev_age_key);
        restore_env_var("MISE_SOPS_AGE_KEY_FILE", prev_age_key_file);
        restore_env_var("MISE_SOPS_ROPS", prev_rops);
        Settings::reset(None);
    }

    #[tokio::test]
    async fn ambient_sops_age_key_file_precedes_exec_env_sops_age_key() {
        let _lock = ENV_MUTEX.lock().await;
        let prev_mise_age_key = crate::env::var("MISE_SOPS_AGE_KEY").ok();
        let prev_sops_age_key = crate::env::var("SOPS_AGE_KEY").ok();
        let prev_sops_age_key_file = crate::env::var("SOPS_AGE_KEY_FILE").ok();
        let prev_rops = crate::env::var("MISE_SOPS_ROPS").ok();
        crate::env::remove_var("MISE_SOPS_AGE_KEY");
        crate::env::remove_var("SOPS_AGE_KEY");
        crate::env::remove_var("MISE_SOPS_ROPS");
        Settings::reset(None);
        let config = Config::reset().await.unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let p = tmp.path().join(".env.toml");
        let key_file = tmp.path().join("age.txt");
        file::write(&p, encrypted_toml()).unwrap();
        file::write(&key_file, AGE_PRIVATE_KEY).unwrap();
        crate::env::set_var("SOPS_AGE_KEY_FILE", key_file.to_string_lossy().to_string());

        let mut exec_env = TeraEnvMap::new();
        exec_env.insert("SOPS_AGE_KEY".into(), "not-an-age-key".into());
        let env = EnvResults::toml(&config, &exec_env, &p, Ok).await.unwrap();
        assert_eq!(env.get("SECRET").unwrap(), "mysecret");

        restore_env_var("MISE_SOPS_AGE_KEY", prev_mise_age_key);
        restore_env_var("SOPS_AGE_KEY", prev_sops_age_key);
        restore_env_var("SOPS_AGE_KEY_FILE", prev_sops_age_key_file);
        restore_env_var("MISE_SOPS_ROPS", prev_rops);
        Settings::reset(None);
    }

    #[tokio::test]
    async fn errors_when_sops_cli_is_configured_for_toml_file() {
        let _lock = ENV_MUTEX.lock().await;
        let prev_age_key = crate::env::var("MISE_SOPS_AGE_KEY").ok();
        let prev_rops = crate::env::var("MISE_SOPS_ROPS").ok();
        crate::env::set_var("MISE_SOPS_AGE_KEY", AGE_PRIVATE_KEY);
        crate::env::set_var("MISE_SOPS_ROPS", "0");
        Settings::reset(None);
        let config = Config::reset().await.unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let p = tmp.path().join(".env.toml");

        file::write(&p, encrypted_toml()).unwrap();

        let exec_env = TeraEnvMap::new();
        let err = EnvResults::toml(&config, &exec_env, &p, Ok)
            .await
            .unwrap_err();
        assert!(
            err.to_string()
                .contains("sops.rops=false is not supported for TOML SOPS files"),
            "{err}"
        );

        restore_env_var("MISE_SOPS_AGE_KEY", prev_age_key);
        restore_env_var("MISE_SOPS_ROPS", prev_rops);
        Settings::reset(None);
    }

    #[test]
    fn escapes_seeded_dotenv_values() {
        assert_eq!(escape_dotenv_double_quoted(r#"a$b"c\d"#), r#"a\$b\"c\\d"#);
        assert_eq!(escape_dotenv_double_quoted("l1\nl2"), "l1\\nl2");
    }

    #[test]
    fn validates_seeded_dotenv_keys() {
        assert!(is_env_key("PGHOST"));
        assert!(is_env_key("_FOO123"));
        assert!(!is_env_key("1FOO"));
        assert!(!is_env_key("FOO-BAR"));
        assert!(!is_env_key(""));
    }
}
