//! AWS named-profile resolution for SigV4.
//!
//! Why this is its own module: the SigV4 signer in [`super::sigv4`] reads
//! credentials from the environment. A workstation user typically keeps
//! credentials in `~/.aws/credentials` and region in `~/.aws/config` under a
//! named profile. This module reads those two files with a small hand-rolled
//! INI parser (no `aws-config` dependency), resolves a profile's access key,
//! secret key, region, and optional session token, and hands them back so the
//! signer can sign without any AWS SDK credential chain.
//!
//! Secret discipline: the secret access key and session token are read into a
//! [`ProfileCredentials`] value and never logged. Parse errors name the file
//! and the missing key, never a value.
//!
//! The standard discovery paths are honored: `AWS_SHARED_CREDENTIALS_FILE`
//! overrides `~/.aws/credentials` and `AWS_CONFIG_FILE` overrides
//! `~/.aws/config`. The public entry points take explicit paths so the core
//! is testable against a temp file.

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

use super::AuthError;

/// Env var that overrides the default credentials file location.
const ENV_CREDENTIALS_FILE: &str = "AWS_SHARED_CREDENTIALS_FILE";
/// Env var that overrides the default config file location.
const ENV_CONFIG_FILE: &str = "AWS_CONFIG_FILE";

/// One profile's resolved SigV4 inputs.
///
/// Carries only what the signer needs. The region is optional here because
/// the YAML / CLI region wins when both are set; the caller decides.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileCredentials {
    /// AWS access key id (not secret, but treated with the same care).
    pub access_key_id: String,
    /// AWS secret access key. Never logged.
    pub secret_access_key: String,
    /// Optional STS session token. Never logged.
    pub session_token: Option<String>,
    /// Region read from the profile, when present.
    pub region: Option<String>,
}

/// Resolve a named profile from the standard AWS file locations, honoring the
/// `AWS_SHARED_CREDENTIALS_FILE` and `AWS_CONFIG_FILE` env overrides and
/// falling back to `~/.aws/credentials` and `~/.aws/config`.
///
/// This is the production entry point the signer calls. It defers the actual
/// parsing to [`resolve_profile_from_paths`] so tests can pin both paths.
pub fn resolve_profile(name: &str) -> Result<ProfileCredentials, AuthError> {
    let credentials_path = default_credentials_path()?;
    let config_path = default_config_path()?;
    resolve_profile_from_paths(name, &credentials_path, &config_path)
}

/// Confirm a named profile resolves from the local AWS files without
/// returning or logging any secret. The doctor pre-flight uses this to
/// report a profile as resolvable (a local, no-network check) while keeping
/// the access key, secret key, and session token inside this module.
pub fn check_profile_resolves(name: &str) -> Result<(), AuthError> {
    resolve_profile(name).map(|_| ())
}

/// Resolve a named profile from explicit credentials and config file paths.
///
/// The credentials file supplies the access key, secret key, and optional
/// session token. The region may come from either file; the credentials file
/// wins when both name a region (mirroring the AWS precedence). A missing
/// credentials file or a profile absent from it is a typed error; a missing
/// config file is not fatal because the region can come from YAML or CLI.
pub fn resolve_profile_from_paths(
    name: &str,
    credentials_path: &Path,
    config_path: &Path,
) -> Result<ProfileCredentials, AuthError> {
    let credentials = read_ini(credentials_path)?;
    let creds_section = credentials
        .get(name)
        .ok_or_else(|| AuthError::InvalidConfig {
            reason: format!(
                "AWS profile `{name}` not found in {}",
                credentials_path.display()
            ),
        })?;

    let access_key_id = require_key(creds_section, "aws_access_key_id", name, credentials_path)?;
    let secret_access_key = require_key(
        creds_section,
        "aws_secret_access_key",
        name,
        credentials_path,
    )?;
    let session_token = creds_section
        .get("aws_session_token")
        .filter(|v| !v.is_empty())
        .cloned();

    let region = creds_section
        .get("region")
        .filter(|v| !v.is_empty())
        .cloned()
        .or_else(|| region_from_config(config_path, name));

    Ok(ProfileCredentials {
        access_key_id,
        secret_access_key,
        session_token,
        region,
    })
}

/// Look up a profile's region in the config file, which uses
/// `[profile NAME]` section headers (except for `default`). Returns `None`
/// when the file is absent or the profile carries no region; the region is
/// optional at this layer.
fn region_from_config(config_path: &Path, name: &str) -> Option<String> {
    let sections = read_ini(config_path).ok()?;
    // The config file uses `[profile NAME]` for non-default profiles and a
    // bare `[default]` for the default one.
    let key = if name == "default" {
        "default".to_string()
    } else {
        format!("profile {name}")
    };
    sections
        .get(&key)
        .and_then(|s| s.get("region"))
        .filter(|v| !v.is_empty())
        .cloned()
}

/// Read a required key from a profile section or return a typed error that
/// names the key and file but never the value.
fn require_key(
    section: &HashMap<String, String>,
    key: &str,
    profile: &str,
    path: &Path,
) -> Result<String, AuthError> {
    section
        .get(key)
        .filter(|v| !v.is_empty())
        .cloned()
        .ok_or_else(|| AuthError::InvalidConfig {
            reason: format!(
                "AWS profile `{profile}` is missing `{key}` in {}",
                path.display()
            ),
        })
}

/// Parse an AWS-style INI file into a map of section name to key/value pairs.
///
/// Hand-rolled rather than pulling an INI crate: the format we need is small
/// (section headers in brackets, `key = value` lines, `#`/`;` comments). Keys
/// are lowercased so lookups are case-insensitive; section names keep their
/// case because `[profile Prod]` is distinct from `[profile prod]` to the SDK.
fn read_ini(path: &Path) -> Result<HashMap<String, HashMap<String, String>>, AuthError> {
    let raw = std::fs::read_to_string(path).map_err(|source| AuthError::Io {
        path: path.to_path_buf(),
        source,
    })?;
    Ok(parse_ini(&raw))
}

/// Parse INI text into sections. Lines outside any section are ignored. A
/// duplicate key within a section keeps the last value, matching the AWS
/// tools' "last one wins" behavior.
fn parse_ini(text: &str) -> HashMap<String, HashMap<String, String>> {
    let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
    let mut current: Option<String> = None;
    for line in text.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
            continue;
        }
        if let Some(section) = section_header(trimmed) {
            sections.entry(section.clone()).or_default();
            current = Some(section);
            continue;
        }
        if let (Some(section), Some((key, value))) = (&current, split_kv(trimmed)) {
            sections
                .entry(section.clone())
                .or_default()
                .insert(key, value);
        }
    }
    sections
}

/// Extract a section name from a `[name]` header line, or `None` when the
/// line is not a well-formed header.
fn section_header(line: &str) -> Option<String> {
    let inner = line.strip_prefix('[')?.strip_suffix(']')?;
    let name = inner.trim();
    if name.is_empty() {
        None
    } else {
        Some(name.to_string())
    }
}

/// Split a `key = value` line into a lowercased key and its trimmed value.
/// Returns `None` when there is no `=`.
fn split_kv(line: &str) -> Option<(String, String)> {
    let (key, value) = line.split_once('=')?;
    let key = key.trim().to_ascii_lowercase();
    if key.is_empty() {
        return None;
    }
    Some((key, value.trim().to_string()))
}

/// Default credentials file path: `AWS_SHARED_CREDENTIALS_FILE` when set,
/// else `~/.aws/credentials`.
fn default_credentials_path() -> Result<PathBuf, AuthError> {
    env_path_or_home(ENV_CREDENTIALS_FILE, "credentials")
}

/// Default config file path: `AWS_CONFIG_FILE` when set, else `~/.aws/config`.
fn default_config_path() -> Result<PathBuf, AuthError> {
    env_path_or_home(ENV_CONFIG_FILE, "config")
}

/// Resolve a path from an env override or `~/.aws/<file_name>`.
fn env_path_or_home(env_var: &str, file_name: &str) -> Result<PathBuf, AuthError> {
    if let Ok(path) = std::env::var(env_var) {
        if !path.is_empty() {
            return Ok(PathBuf::from(path));
        }
    }
    let home = etcetera::home_dir().map_err(|_| AuthError::Io {
        path: PathBuf::from("$HOME/.aws"),
        source: std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "could not resolve home directory for AWS profile lookup",
        ),
    })?;
    Ok(home.join(".aws").join(file_name))
}

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

    fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
        let path = dir.join(name);
        let mut file = std::fs::File::create(&path).unwrap();
        file.write_all(contents.as_bytes()).unwrap();
        path
    }

    #[test]
    fn resolves_keys_and_session_token_from_credentials_file() {
        let dir = tempfile::tempdir().unwrap();
        let creds = write_file(
            dir.path(),
            "credentials",
            "[prod]\naws_access_key_id = AKIDEXAMPLE\naws_secret_access_key = secretvalue\naws_session_token = tokenvalue\n",
        );
        let config = dir.path().join("config");
        let resolved = resolve_profile_from_paths("prod", &creds, &config).unwrap();
        assert_eq!(resolved.access_key_id, "AKIDEXAMPLE");
        assert_eq!(resolved.secret_access_key, "secretvalue");
        assert_eq!(resolved.session_token.as_deref(), Some("tokenvalue"));
    }

    #[test]
    fn region_comes_from_config_when_credentials_omits_it() {
        let dir = tempfile::tempdir().unwrap();
        let creds = write_file(
            dir.path(),
            "credentials",
            "[prod]\naws_access_key_id = AK\naws_secret_access_key = sk\n",
        );
        let config = write_file(
            dir.path(),
            "config",
            "[profile prod]\nregion = eu-west-1\noutput = json\n",
        );
        let resolved = resolve_profile_from_paths("prod", &creds, &config).unwrap();
        assert_eq!(resolved.region.as_deref(), Some("eu-west-1"));
    }

    #[test]
    fn credentials_region_wins_over_config_region() {
        let dir = tempfile::tempdir().unwrap();
        let creds = write_file(
            dir.path(),
            "credentials",
            "[prod]\naws_access_key_id = AK\naws_secret_access_key = sk\nregion = us-east-1\n",
        );
        let config = write_file(dir.path(), "config", "[profile prod]\nregion = eu-west-1\n");
        let resolved = resolve_profile_from_paths("prod", &creds, &config).unwrap();
        assert_eq!(resolved.region.as_deref(), Some("us-east-1"));
    }

    #[test]
    fn default_profile_reads_bare_default_section_in_config() {
        let dir = tempfile::tempdir().unwrap();
        let creds = write_file(
            dir.path(),
            "credentials",
            "[default]\naws_access_key_id = AK\naws_secret_access_key = sk\n",
        );
        let config = write_file(dir.path(), "config", "[default]\nregion = ap-south-1\n");
        let resolved = resolve_profile_from_paths("default", &creds, &config).unwrap();
        assert_eq!(resolved.region.as_deref(), Some("ap-south-1"));
    }

    #[test]
    fn missing_profile_is_a_typed_error() {
        let dir = tempfile::tempdir().unwrap();
        let creds = write_file(
            dir.path(),
            "credentials",
            "[other]\naws_access_key_id = AK\naws_secret_access_key = sk\n",
        );
        let config = dir.path().join("config");
        let err = resolve_profile_from_paths("prod", &creds, &config).unwrap_err();
        assert!(matches!(err, AuthError::InvalidConfig { .. }));
    }

    #[test]
    fn missing_secret_key_is_a_typed_error_without_leaking_value() {
        let dir = tempfile::tempdir().unwrap();
        let creds = write_file(
            dir.path(),
            "credentials",
            "[prod]\naws_access_key_id = AKIDEXAMPLE\n",
        );
        let config = dir.path().join("config");
        let err = resolve_profile_from_paths("prod", &creds, &config).unwrap_err();
        match err {
            AuthError::InvalidConfig { reason } => {
                assert!(reason.contains("aws_secret_access_key"));
                assert!(reason.contains("prod"));
            }
            other => panic!("expected InvalidConfig, got {other:?}"),
        }
    }

    #[test]
    fn missing_credentials_file_is_an_io_error() {
        let dir = tempfile::tempdir().unwrap();
        let creds = dir.path().join("does-not-exist");
        let config = dir.path().join("config");
        let err = resolve_profile_from_paths("prod", &creds, &config).unwrap_err();
        assert!(matches!(err, AuthError::Io { .. }));
    }

    #[test]
    fn parser_ignores_comments_and_blank_lines() {
        let text = "# a comment\n\n[prod]\n; another comment\naws_access_key_id = AK\n\naws_secret_access_key = sk\n";
        let sections = parse_ini(text);
        let prod = sections.get("prod").expect("prod section");
        assert_eq!(
            prod.get("aws_access_key_id").map(String::as_str),
            Some("AK")
        );
        assert_eq!(
            prod.get("aws_secret_access_key").map(String::as_str),
            Some("sk")
        );
    }

    #[test]
    fn parser_last_duplicate_key_wins() {
        let text = "[prod]\nregion = us-east-1\nregion = eu-west-1\n";
        let sections = parse_ini(text);
        assert_eq!(
            sections
                .get("prod")
                .and_then(|s| s.get("region"))
                .map(String::as_str),
            Some("eu-west-1")
        );
    }

    #[test]
    fn empty_session_token_is_treated_as_absent() {
        let dir = tempfile::tempdir().unwrap();
        let creds = write_file(
            dir.path(),
            "credentials",
            "[prod]\naws_access_key_id = AK\naws_secret_access_key = sk\naws_session_token =\n",
        );
        let config = dir.path().join("config");
        let resolved = resolve_profile_from_paths("prod", &creds, &config).unwrap();
        assert_eq!(resolved.session_token, None);
    }

    #[test]
    fn env_override_path_is_honored() {
        let _lock = crate::auth::aws_env_lock();
        // Point the credentials file env override at a temp file and confirm
        // the default-path resolver reads it.
        let dir = tempfile::tempdir().unwrap();
        let creds = write_file(
            dir.path(),
            "credentials",
            "[prod]\naws_access_key_id = AKENV\naws_secret_access_key = skenv\n",
        );
        std::env::set_var(ENV_CREDENTIALS_FILE, &creds);
        let resolved = default_credentials_path();
        std::env::remove_var(ENV_CREDENTIALS_FILE);
        assert_eq!(resolved.unwrap(), creds);
    }
}
