//! Pull configuration — parsing `.tsafe.yml` / `.tsafe.json` repo manifests.
//!
//! A pull config file declares one or more [`PullSource`]s (Azure Key Vault,
//! HashiCorp Vault, 1Password) that `tsafe pull` reads secrets from.  The file
//! is searched upward from the current directory via [`find_config`].
//!
//! # ADR-012 fields
//!
//! Every source entry may declare two optional fields defined by ADR-012:
//!
//! - `name`: a label used by `--source <label>` filtering.  Sources without a
//!   `name` field are always included in unfiltered runs but cannot be selected
//!   with `--source`.
//! - `ns`: a namespace prefix applied to every key fetched from this source.
//!   A key named `DB_PASSWORD` from a source with `ns: prod` is stored as
//!   `prod.DB_PASSWORD`.  The separator is `.`, matching the vault's existing
//!   namespace convention.
//!
//! # HCP Vault auth fields (task E2.4)
//!
//! The `hcp` source variant supports two authentication methods via the `auth`
//! sub-key:
//!
//! ```yaml
//! # Token auth (legacy default — reads VAULT_TOKEN env var at runtime):
//! pulls:
//!   - source: hcp
//!     auth:
//!       method: token
//!
//! # AppRole auth (machine identity):
//! pulls:
//!   - source: hcp
//!     auth:
//!       method: approle
//!       role_id: my-role
//!       secret_id: ${VAULT_SECRET_ID}   # env var expansion supported
//! ```
//!
//! Namespace support (HCP Vault Enterprise):
//! ```yaml
//! pulls:
//!   - source: hcp
//!     vault_url: https://vault.example.com:8200
//!     vault_namespace: team-alpha
//! ```

use std::path::{Path, PathBuf};

use serde::Deserialize;

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

// ── HCP Vault auth config ─────────────────────────────────────────────────────

/// Authentication method declared in the pull manifest for a HashiCorp Vault source.
///
/// When omitted from the YAML, the runtime falls back to environment variables
/// (`VAULT_ROLE_ID` + `VAULT_SECRET_ID` for AppRole, then `VAULT_TOKEN` for token).
///
/// Values that look like `${ENV_VAR}` are expanded at parse time via
/// [`Self::expand_env_vars`].
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(tag = "method", rename_all = "lowercase")]
pub enum VaultAuthConfig {
    /// Static token (reads `VAULT_TOKEN` at runtime if `token` is not set here).
    Token {
        #[serde(default)]
        token: Option<String>,
    },
    /// AppRole: exchange `role_id` + `secret_id` for a short-lived client token.
    Approle { role_id: String, secret_id: String },
}

impl VaultAuthConfig {
    /// Expand `${VAR}` placeholders in string fields using the current process
    /// environment.  Unknown variables are left as-is (not expanded to empty string).
    pub fn expand_env_vars(self) -> Self {
        match self {
            VaultAuthConfig::Approle { role_id, secret_id } => VaultAuthConfig::Approle {
                role_id: expand_env_var_str(&role_id),
                secret_id: expand_env_var_str(&secret_id),
            },
            other => other,
        }
    }
}

/// Expand a single `${VAR_NAME}` placeholder in `s`.
///
/// Only the simple `${NAME}` syntax is supported — no nested expansions,
/// no default values.  If a variable is not set, the original `${NAME}`
/// placeholder is left in the string.
pub fn expand_env_var_str(s: &str) -> String {
    // Fast path: no placeholder present.
    if !s.contains("${") {
        return s.to_string();
    }

    let mut result = String::with_capacity(s.len());
    let mut rest = s;

    while let Some(start) = rest.find("${") {
        result.push_str(&rest[..start]);
        rest = &rest[start + 2..]; // skip past "${"
        if let Some(end) = rest.find('}') {
            let var_name = &rest[..end];
            match std::env::var(var_name) {
                Ok(val) => result.push_str(&val),
                Err(_) => {
                    // Leave unexpanded so callers can detect missing vars.
                    result.push_str("${");
                    result.push_str(var_name);
                    result.push('}');
                }
            }
            rest = &rest[end + 1..];
        } else {
            // Malformed placeholder — emit literally and stop.
            result.push_str("${");
            result.push_str(rest);
            break;
        }
    }
    result.push_str(rest);
    result
}

/// Top-level pull configuration parsed from `.tsafe.yml` or `.tsafe.json`.
#[derive(Debug, Deserialize)]
pub struct PullConfig {
    pub pulls: Vec<PullSource>,
}

/// A single pull source definition.
///
/// Every variant includes two ADR-012 optional fields:
/// - `name`: label for `--source <label>` filtering
/// - `ns`: namespace prefix applied to fetched keys (separator `.`)
#[derive(Debug, Deserialize)]
#[serde(tag = "source")]
pub enum PullSource {
    /// Azure Key Vault.
    #[serde(rename = "akv")]
    AzureKeyVault {
        /// Optional label for `--source <label>` filtering (ADR-012).
        #[serde(default)]
        name: Option<String>,
        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
        #[serde(default)]
        ns: Option<String>,
        vault_url: String,
        #[serde(default)]
        prefix: Option<String>,
        #[serde(default)]
        overwrite: bool,
    },
    /// HashiCorp Vault KV v2.
    #[serde(rename = "hcp")]
    HashiCorpVault {
        /// Optional label for `--source <label>` filtering (ADR-012).
        #[serde(default)]
        name: Option<String>,
        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
        #[serde(default)]
        ns: Option<String>,
        #[serde(default = "default_hcp_addr")]
        addr: String,
        #[serde(default = "default_mount")]
        mount: String,
        #[serde(default)]
        prefix: Option<String>,
        #[serde(default)]
        overwrite: bool,
        /// Authentication method.  When absent, the runtime reads env vars
        /// (`VAULT_ROLE_ID`+`VAULT_SECRET_ID` → AppRole; else `VAULT_TOKEN`).
        #[serde(default)]
        auth: Option<VaultAuthConfig>,
        /// HCP Vault Enterprise namespace.  When set, every request carries
        /// `X-Vault-Namespace: <vault_namespace>`.  Also read from `VAULT_NAMESPACE`.
        #[serde(default)]
        vault_namespace: Option<String>,
    },
    /// 1Password via the `op` CLI.
    #[serde(rename = "op")]
    OnePassword {
        /// Optional label for `--source <label>` filtering (ADR-012).
        #[serde(default)]
        name: Option<String>,
        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
        #[serde(default)]
        ns: Option<String>,
        item: String,
        #[serde(default)]
        op_vault: Option<String>,
        #[serde(default)]
        overwrite: bool,
    },
    /// AWS Secrets Manager.
    #[serde(rename = "aws")]
    Aws {
        /// Optional label for `--source <label>` filtering (ADR-012).
        #[serde(default)]
        name: Option<String>,
        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
        #[serde(default)]
        ns: Option<String>,
        /// AWS region, e.g. `us-east-1`. Overrides `AWS_DEFAULT_REGION`/`AWS_REGION`.
        #[serde(default)]
        region: Option<String>,
        /// Only import secrets whose names start with this prefix.
        #[serde(default)]
        prefix: Option<String>,
        #[serde(default)]
        overwrite: bool,
    },
    /// AWS SSM Parameter Store.
    #[serde(rename = "ssm")]
    SsmParameterStore {
        /// Optional label for `--source <label>` filtering (ADR-012).
        #[serde(default)]
        name: Option<String>,
        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
        #[serde(default)]
        ns: Option<String>,
        /// AWS region, e.g. `us-east-1`. Overrides `AWS_DEFAULT_REGION`/`AWS_REGION`.
        #[serde(default)]
        region: Option<String>,
        /// Parameter path prefix (e.g. `/myapp/prod/`). Defaults to `/`.
        #[serde(default)]
        path: Option<String>,
        #[serde(default)]
        overwrite: bool,
    },
    /// GCP Secret Manager.
    #[serde(rename = "gcp")]
    Gcp {
        /// Optional label for `--source <label>` filtering (ADR-012).
        #[serde(default)]
        name: Option<String>,
        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
        #[serde(default)]
        ns: Option<String>,
        /// GCP project ID. Overrides `GOOGLE_CLOUD_PROJECT`/`GCLOUD_PROJECT`.
        #[serde(default)]
        project: Option<String>,
        /// Only import secrets whose names start with this prefix.
        #[serde(default)]
        prefix: Option<String>,
        #[serde(default)]
        overwrite: bool,
    },
    /// Bitwarden via the `bw` CLI (task E2.2).
    ///
    /// Cipher values in the Bitwarden REST API are always E2E encrypted
    /// client-side; this source uses the `bw` CLI subprocess to unlock and
    /// list items with plaintext decryption handled by the CLI.
    ///
    /// Auth requires `TSAFE_BW_CLIENT_ID`, `TSAFE_BW_CLIENT_SECRET`, and
    /// `TSAFE_BW_PASSWORD` (master password for `bw unlock`).
    #[serde(rename = "bw")]
    Bitwarden {
        /// Optional label for `--source <label>` filtering (ADR-012).
        #[serde(default)]
        name: Option<String>,
        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
        #[serde(default)]
        ns: Option<String>,
        /// Bitwarden API base URL.  Defaults to `https://api.bitwarden.com`.
        /// Override for self-hosted Vaultwarden instances.
        #[serde(default)]
        api_url: Option<String>,
        /// Bitwarden identity base URL.  Defaults to `https://identity.bitwarden.com`.
        #[serde(default)]
        identity_url: Option<String>,
        /// OAuth2 client ID.  Reads `TSAFE_BW_CLIENT_ID` when not set here.
        #[serde(default)]
        client_id: Option<String>,
        /// OAuth2 client secret.  Reads `TSAFE_BW_CLIENT_SECRET` when not set here.
        #[serde(default)]
        client_secret: Option<String>,
        /// Bitwarden folder ID to filter items.  Imports all items when absent.
        #[serde(default)]
        folder: Option<String>,
        /// Name of the env var that holds the master password for `bw unlock`.
        /// Defaults to `TSAFE_BW_PASSWORD`.
        #[serde(default)]
        password_env: Option<String>,
        #[serde(default)]
        overwrite: bool,
    },
    /// KeePass `.kdbx` file (local path).
    ///
    /// The master password is read from the env var named by `password_env`
    /// (never stored literally in the manifest).  An optional key file can
    /// supplement or replace the password.
    #[serde(rename = "kp")]
    Keepass {
        /// Optional label for `--source <label>` filtering (ADR-012).
        #[serde(default)]
        name: Option<String>,
        /// Absolute path to the `.kdbx` file.
        path: String,
        /// Name of the environment variable that holds the master password.
        /// If omitted and no `keyfile_path` is set, opening the database will fail.
        #[serde(default)]
        password_env: Option<String>,
        /// Absolute path to a KeePass key file (`.keyx` or binary).
        #[serde(default)]
        keyfile_path: Option<String>,
        /// Only import entries whose direct parent group has this name
        /// (case-insensitive).  When absent, all entries from the root group
        /// are imported (or all groups when `recursive` is true).
        #[serde(default)]
        group: Option<String>,
        /// When `true`, traverse descendant groups as well as the matched
        /// top-level group.  Defaults to `false`.
        #[serde(default)]
        recursive: Option<bool>,
        /// Optional namespace prefix; keys become `<ns>.KEY_NAME` (ADR-012).
        #[serde(default)]
        ns: Option<String>,
        #[serde(default)]
        overwrite: bool,
    },
}

impl PullSource {
    /// Return the `name` label for this source, if declared (ADR-012).
    pub fn name(&self) -> Option<&str> {
        match self {
            PullSource::AzureKeyVault { name, .. }
            | PullSource::HashiCorpVault { name, .. }
            | PullSource::OnePassword { name, .. }
            | PullSource::Aws { name, .. }
            | PullSource::SsmParameterStore { name, .. }
            | PullSource::Gcp { name, .. }
            | PullSource::Bitwarden { name, .. } => name.as_deref(),
            PullSource::Keepass { name, .. } => name.as_deref(),
        }
    }

    /// Return the `ns` namespace prefix for this source, if declared (ADR-012).
    ///
    /// Keys fetched from a source with `ns` set are stored as `<ns>.KEY_NAME`.
    pub fn ns(&self) -> Option<&str> {
        match self {
            PullSource::AzureKeyVault { ns, .. }
            | PullSource::HashiCorpVault { ns, .. }
            | PullSource::OnePassword { ns, .. }
            | PullSource::Aws { ns, .. }
            | PullSource::SsmParameterStore { ns, .. }
            | PullSource::Gcp { ns, .. }
            | PullSource::Bitwarden { ns, .. } => ns.as_deref(),
            PullSource::Keepass { ns, .. } => ns.as_deref(),
        }
    }

    /// Return a human-readable provider type label for display purposes.
    pub fn provider_type(&self) -> &'static str {
        match self {
            PullSource::AzureKeyVault { .. } => "akv",
            PullSource::HashiCorpVault { .. } => "hcp",
            PullSource::OnePassword { .. } => "op",
            PullSource::Aws { .. } => "aws",
            PullSource::SsmParameterStore { .. } => "ssm",
            PullSource::Gcp { .. } => "gcp",
            PullSource::Bitwarden { .. } => "bw",
            PullSource::Keepass { .. } => "kp",
        }
    }
}

fn default_hcp_addr() -> String {
    "http://127.0.0.1:8200".into()
}
fn default_mount() -> String {
    "secret".into()
}

/// Search upward from `start` for `.tsafe.yml` / `.tsafe.json`.
pub fn find_config(start: &Path) -> Option<PathBuf> {
    let mut dir = start.to_path_buf();
    loop {
        let yml = dir.join(".tsafe.yml");
        if yml.exists() {
            return Some(yml);
        }
        let json = dir.join(".tsafe.json");
        if json.exists() {
            return Some(json);
        }
        if !dir.pop() {
            return None;
        }
    }
}

/// Parse a pull configuration file (YAML or JSON).
pub fn load(path: &Path) -> SafeResult<PullConfig> {
    let content = std::fs::read_to_string(path)?;
    let is_json = path
        .extension()
        .and_then(|e| e.to_str())
        .map(|e| e == "json")
        .unwrap_or(false);
    if is_json {
        serde_json::from_str(&content).map_err(|e| SafeError::InvalidVault {
            reason: format!("invalid pull config JSON: {e}"),
        })
    } else {
        serde_yaml::from_str(&content).map_err(|e| SafeError::InvalidVault {
            reason: format!("invalid pull config YAML: {e}"),
        })
    }
}

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

    #[test]
    fn parse_yaml_config() {
        let yaml = r#"
pulls:
  - source: akv
    vault_url: https://myvault.vault.azure.net
    prefix: MYAPP_
    overwrite: true
  - source: hcp
    addr: http://vault:8200
    mount: secret
    prefix: myapp/
  - source: op
    item: Database Credentials
    op_vault: Infrastructure
  - source: aws
    region: us-east-1
    prefix: myapp/
  - source: gcp
    project: my-gcp-project
    prefix: myapp-
"#;
        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(cfg.pulls.len(), 5);
        match &cfg.pulls[0] {
            PullSource::AzureKeyVault {
                vault_url,
                prefix,
                overwrite,
                ..
            } => {
                assert_eq!(vault_url, "https://myvault.vault.azure.net");
                assert_eq!(prefix.as_deref(), Some("MYAPP_"));
                assert!(overwrite);
            }
            other => panic!("expected AzureKeyVault, got {other:?}"),
        }
    }

    #[test]
    fn parse_json_config() {
        let json = r#"{"pulls": [{"source": "op", "item": "Test"}]}"#;
        let cfg: PullConfig = serde_json::from_str(json).unwrap();
        assert_eq!(cfg.pulls.len(), 1);
    }

    #[test]
    fn find_config_walks_up() {
        let dir = tempdir().unwrap();
        let child = dir.path().join("a/b/c");
        std::fs::create_dir_all(&child).unwrap();
        let cfg_path = dir.path().join(".tsafe.yml");
        std::fs::write(&cfg_path, "pulls: []").unwrap();
        let found = find_config(&child).unwrap();
        assert_eq!(found, cfg_path);
    }

    #[test]
    fn find_config_returns_none() {
        let dir = tempdir().unwrap();
        assert!(find_config(dir.path()).is_none());
    }

    /// ADR-012: `name` and `ns` fields are optional and parse correctly when present.
    #[test]
    fn parse_name_and_ns_fields() {
        let yaml = r#"
pulls:
  - source: akv
    name: prod-akv
    ns: prod
    vault_url: https://prod.vault.azure.net
  - source: aws
    name: staging-aws
    ns: staging
    region: us-east-1
  - source: gcp
    project: my-project
"#;
        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(cfg.pulls.len(), 3);

        assert_eq!(cfg.pulls[0].name(), Some("prod-akv"));
        assert_eq!(cfg.pulls[0].ns(), Some("prod"));
        assert_eq!(cfg.pulls[0].provider_type(), "akv");

        assert_eq!(cfg.pulls[1].name(), Some("staging-aws"));
        assert_eq!(cfg.pulls[1].ns(), Some("staging"));
        assert_eq!(cfg.pulls[1].provider_type(), "aws");

        // source without name/ns defaults to None
        assert_eq!(cfg.pulls[2].name(), None);
        assert_eq!(cfg.pulls[2].ns(), None);
        assert_eq!(cfg.pulls[2].provider_type(), "gcp");
    }

    /// ADR-012: existing manifests without name/ns continue to parse unchanged.
    #[test]
    fn name_and_ns_default_to_none() {
        let yaml = r#"
pulls:
  - source: akv
    vault_url: https://myvault.vault.azure.net
  - source: hcp
    addr: http://vault:8200
    mount: secret
  - source: op
    item: MyItem
  - source: aws
    region: us-east-1
  - source: ssm
    region: us-east-1
  - source: gcp
    project: my-project
"#;
        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
        for source in &cfg.pulls {
            assert_eq!(
                source.name(),
                None,
                "expected no name for {:?}",
                source.provider_type()
            );
            assert_eq!(
                source.ns(),
                None,
                "expected no ns for {:?}",
                source.provider_type()
            );
        }
    }

    // ── VaultAuthConfig tests (task E2.4) ──────────────────────────────────────

    /// Token auth parsed from YAML.
    #[test]
    fn parse_hcp_token_auth_from_yaml() {
        let yaml = r#"
pulls:
  - source: hcp
    addr: https://vault.example.com:8200
    auth:
      method: token
      token: hvs.my-static-token
"#;
        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(cfg.pulls.len(), 1);
        match &cfg.pulls[0] {
            PullSource::HashiCorpVault { auth, .. } => {
                assert!(
                    matches!(
                        auth,
                        Some(VaultAuthConfig::Token {
                            token: Some(t)
                        }) if t == "hvs.my-static-token"
                    ),
                    "expected Token auth with static token, got {auth:?}"
                );
            }
            other => panic!("expected HashiCorpVault, got {other:?}"),
        }
    }

    /// AppRole auth parsed from YAML.
    #[test]
    fn parse_hcp_approle_auth_from_yaml() {
        let yaml = r#"
pulls:
  - source: hcp
    addr: https://vault.example.com:8200
    auth:
      method: approle
      role_id: my-role-123
      secret_id: my-secret-456
"#;
        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
        match &cfg.pulls[0] {
            PullSource::HashiCorpVault { auth, .. } => {
                assert!(
                    matches!(
                        auth,
                        Some(VaultAuthConfig::Approle { role_id, secret_id })
                        if role_id == "my-role-123" && secret_id == "my-secret-456"
                    ),
                    "expected AppRole auth, got {auth:?}"
                );
            }
            other => panic!("expected HashiCorpVault, got {other:?}"),
        }
    }

    /// vault_namespace field is parsed from YAML.
    #[test]
    fn parse_hcp_vault_namespace_from_yaml() {
        let yaml = r#"
pulls:
  - source: hcp
    addr: https://vault.example.com:8200
    vault_namespace: team-alpha
"#;
        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
        match &cfg.pulls[0] {
            PullSource::HashiCorpVault {
                vault_namespace, ..
            } => {
                assert_eq!(vault_namespace.as_deref(), Some("team-alpha"));
            }
            other => panic!("expected HashiCorpVault, got {other:?}"),
        }
    }

    /// HCP source without auth or vault_namespace defaults to None.
    #[test]
    fn parse_hcp_defaults_auth_and_namespace_to_none() {
        let yaml = r#"
pulls:
  - source: hcp
    addr: http://127.0.0.1:8200
"#;
        let cfg: PullConfig = serde_yaml::from_str(yaml).unwrap();
        match &cfg.pulls[0] {
            PullSource::HashiCorpVault {
                auth,
                vault_namespace,
                ..
            } => {
                assert!(auth.is_none(), "expected auth=None, got {auth:?}");
                assert!(
                    vault_namespace.is_none(),
                    "expected vault_namespace=None, got {vault_namespace:?}"
                );
            }
            other => panic!("expected HashiCorpVault, got {other:?}"),
        }
    }

    // ── expand_env_var_str tests ───────────────────────────────────────────────

    /// Simple `${VAR}` placeholder is expanded when the var is set.
    #[test]
    fn expand_env_var_str_replaces_placeholder() {
        temp_env::with_var("TEST_SECRET_ID", Some("s-abc-123"), || {
            let result = expand_env_var_str("${TEST_SECRET_ID}");
            assert_eq!(result, "s-abc-123");
        });
    }

    /// Strings without `${` are returned unchanged (fast path).
    #[test]
    fn expand_env_var_str_no_placeholder_passthrough() {
        let result = expand_env_var_str("plain-secret-id");
        assert_eq!(result, "plain-secret-id");
    }

    /// Unknown variable placeholder is left unexpanded.
    #[test]
    fn expand_env_var_str_unknown_var_left_as_is() {
        temp_env::with_var("VAULT_UNKNOWN_9999", None::<&str>, || {
            let result = expand_env_var_str("${VAULT_UNKNOWN_9999}");
            assert_eq!(result, "${VAULT_UNKNOWN_9999}");
        });
    }

    /// Env var expansion in AppRole auth config via `expand_env_vars()`.
    #[test]
    fn vault_auth_config_expand_env_vars_in_approle() {
        temp_env::with_var("MY_SECRET_ID", Some("expanded-sid"), || {
            let auth = VaultAuthConfig::Approle {
                role_id: "static-role".into(),
                secret_id: "${MY_SECRET_ID}".into(),
            };
            let expanded = auth.expand_env_vars();
            assert!(
                matches!(
                    expanded,
                    VaultAuthConfig::Approle { ref role_id, ref secret_id }
                    if role_id == "static-role" && secret_id == "expanded-sid"
                ),
                "expected expanded secret_id, got {expanded:?}"
            );
        });
    }

    /// Token auth is not modified by expand_env_vars().
    #[test]
    fn vault_auth_config_expand_env_vars_token_unchanged() {
        let auth = VaultAuthConfig::Token {
            token: Some("hvs.static".into()),
        };
        let expanded = auth.expand_env_vars();
        assert!(
            matches!(expanded, VaultAuthConfig::Token { token: Some(ref t) } if t == "hvs.static"),
            "expected token unchanged, got {expanded:?}"
        );
    }
}
