//! Resolves AWS credentials from CLI args, env vars, and AWS profile files.
//!
//! The public entry point is [`resolve`], which merges all sources with the
//! standard AWS precedence: CLI flag / env var (already read by clap) →
//! profile file → built-in default.

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

use exn::{OptionExt as _, ResultExt as _, bail};
use ini::Ini;

// ── INI key names ────────────────────────────────────────────────────────────

/// INI key for the AWS access key ID.
const KEY_ACCESS_KEY_ID: &str = "aws_access_key_id";

/// INI key for the AWS secret access key.
const KEY_SECRET_ACCESS_KEY: &str = "aws_secret_access_key";

/// INI key for an optional STS session token.
const KEY_SESSION_TOKEN: &str = "aws_session_token";

/// INI key for the AWS region.
const KEY_REGION: &str = "region";

// ── Fixed values ─────────────────────────────────────────────────────────────

/// Fallback region when nothing else specifies one.
const DEFAULT_REGION: &str = "us-east-1";

/// Profile name used when neither `--profile` nor `AWS_PROFILE` is set.
const DEFAULT_PROFILE: &str = "default";

/// Section-name prefix used for named profiles inside `~/.aws/config`.
///
/// Named profiles appear as `[profile foo]` in config but as `[foo]` in
/// credentials; the `default` profile is always `[default]` in both files.
const CONFIG_PROFILE_PREFIX: &str = "profile ";

// ── Env var names ─────────────────────────────────────────────────────────────

/// Env var that overrides the path to `~/.aws/credentials`.
const ENV_CREDENTIALS_FILE: &str = "AWS_SHARED_CREDENTIALS_FILE";

/// Env var that overrides the path to `~/.aws/config`.
const ENV_CONFIG_FILE: &str = "AWS_CONFIG_FILE";

// ── Public types ─────────────────────────────────────────────────────────────

/// Fully resolved credential set, ready for use in the `SigV4` pipeline.
#[derive(Clone, Debug)]
pub struct ResolvedCredentials {
    /// AWS access key ID.
    pub access_key: String,
    /// AWS secret access key.
    pub secret_key: String,
    /// STS session token, present only for temporary credentials.
    pub session_token: Option<String>,
    /// AWS region; always populated (defaults to `us-east-1`).
    pub region: String,
}

/// Errors returned by [`resolve`].
#[derive(Clone, Debug, derive_more::Display)]
pub enum ProfileError {
    /// The credentials file could not be read or parsed.
    #[display("reading credentials file {path:?}")]
    ReadCredentials {
        /// Path that could not be read.
        path: PathBuf,
    },
    /// The config file could not be read or parsed.
    #[display("reading config file {path:?}")]
    ReadConfig {
        /// Path that could not be read.
        path: PathBuf,
    },
    /// The requested profile was not found in either file.
    #[display("profile {name:?} not found in {path:?}")]
    ProfileNotFound {
        /// The profile name that was requested.
        name: String,
        /// The credentials file path that was checked.
        path: PathBuf,
    },
    /// No access key could be resolved from any source.
    #[display(
        "missing AWS access key — pass --access-key, set AWS_ACCESS_KEY_ID, or configure a profile"
    )]
    MissingAccessKey,
    /// No secret key could be resolved from any source.
    #[display(
        "missing AWS secret key — pass --secret-key, set AWS_SECRET_ACCESS_KEY, or configure a profile"
    )]
    MissingSecretKey,
    /// The home directory could not be determined.
    #[display(
        "cannot locate home directory; set HOME or AWS_SHARED_CREDENTIALS_FILE / AWS_CONFIG_FILE"
    )]
    NoHomeDir,
}

impl std::error::Error for ProfileError {}

// ── Private helpers ───────────────────────────────────────────────────────────

/// Credential fields already resolved by clap from CLI flags or env vars.
///
/// Each field is `None` when the value was not supplied; `resolve_from_ini`
/// falls back to the profile files for every absent field.
struct CliInput<'a> {
    /// Overrides `aws_access_key_id` from the profile.
    access_key: Option<&'a str>,
    /// Overrides `aws_secret_access_key` from the profile.
    secret_key: Option<&'a str>,
    /// Overrides `aws_session_token` from the profile.
    session_token: Option<&'a str>,
    /// Overrides `region` from the profile config.
    region: Option<&'a str>,
}

// ── Public entry point ────────────────────────────────────────────────────────

/// Resolves final credentials by merging CLI args, env vars, and profile files.
///
/// `profile_name` is `Some` when `--profile` or `AWS_PROFILE` was set; `None`
/// selects the `default` profile.  The `cli_*` parameters hold values that
/// clap already resolved (CLI flag > env var); `None` means "not provided".
///
/// If `profile_name` is `Some` and the named profile cannot be found in either
/// file, returns [`ProfileError::ProfileNotFound`].
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(
        level = "trace",
        skip(cli_access_key, cli_secret_key, cli_session_token),
        ret,
        err(level = "debug")
    )
)]
pub fn resolve(
    profile_name: Option<&str>,
    cli_access_key: Option<&str>,
    cli_secret_key: Option<&str>,
    cli_session_token: Option<&str>,
    cli_region: Option<&str>,
) -> exn::Result<ResolvedCredentials, ProfileError> {
    let profile = profile_name.unwrap_or(DEFAULT_PROFILE);
    let explicit = profile_name.is_some();

    let (cred_path, cred_required) = credentials_path()?;
    let (config_path, config_required) = config_file_path()?;

    let cred_ini = load_ini(&cred_path, cred_required, |p| {
        ProfileError::ReadCredentials { path: p }
    })?;
    let config_ini = load_ini(&config_path, config_required, |p| {
        ProfileError::ReadConfig { path: p }
    })?;

    resolve_from_ini(
        cred_ini.as_ref(),
        config_ini.as_ref(),
        profile,
        explicit,
        &cred_path,
        CliInput {
            access_key: cli_access_key,
            secret_key: cli_secret_key,
            session_token: cli_session_token,
            region: cli_region,
        },
    )
}

// ── Private helpers (continued) ───────────────────────────────────────────────

/// Returns the path to the credentials file and whether it is required.
///
/// Required means the path was explicitly set via `AWS_SHARED_CREDENTIALS_FILE`;
/// optional means it is the default `~/.aws/credentials`, which is silently
/// skipped when absent.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(level = "trace", ret, err(level = "debug"))
)]
fn credentials_path() -> exn::Result<(PathBuf, bool), ProfileError> {
    if let Ok(p) = env::var(ENV_CREDENTIALS_FILE) {
        return Ok((PathBuf::from(p), true));
    }
    let home = env::var("HOME").or_raise(|| ProfileError::NoHomeDir)?;
    Ok((PathBuf::from(home).join(".aws").join("credentials"), false))
}

/// Returns the path to the config file and whether it is required.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(level = "trace", ret, err(level = "debug"))
)]
fn config_file_path() -> exn::Result<(PathBuf, bool), ProfileError> {
    if let Ok(p) = env::var(ENV_CONFIG_FILE) {
        return Ok((PathBuf::from(p), true));
    }
    let home = env::var("HOME").or_raise(|| ProfileError::NoHomeDir)?;
    Ok((PathBuf::from(home).join(".aws").join("config"), false))
}

/// Loads an INI file from `path`, returning `None` when the file is absent and
/// `required` is `false`.  Any other error (including "not found" when
/// `required` is `true`) calls `make_err` and bails.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(level = "trace", skip(make_err), ret, err(level = "debug"))
)]
fn load_ini(
    path: &Path,
    required: bool,
    make_err: impl Fn(PathBuf) -> ProfileError,
) -> exn::Result<Option<Ini>, ProfileError> {
    match Ini::load_from_file(path) {
        Ok(ini) => Ok(Some(ini)),
        Err(ini::Error::Io(ref e)) if !required && e.kind() == std::io::ErrorKind::NotFound => {
            Ok(None)
        },
        Err(_) => bail!(make_err(path.to_owned())),
    }
}

/// Pure credential resolution from pre-loaded INI data.
///
/// Separated from `resolve` so it can be unit-tested without touching the
/// filesystem or environment variables.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(
        level = "trace",
        skip(cred_ini, config_ini, cli),
        ret,
        err(level = "debug")
    )
)]
fn resolve_from_ini(
    cred_ini: Option<&Ini>,
    config_ini: Option<&Ini>,
    profile: &str,
    explicit_profile: bool,
    cred_path: &Path,
    cli: CliInput<'_>,
) -> exn::Result<ResolvedCredentials, ProfileError> {
    let cred_section = cred_ini.and_then(|ini| ini.section(Some(profile)));

    // ~/.aws/config uses `[profile foo]` for named profiles, `[default]` for default.
    let config_section_name: String = if profile == DEFAULT_PROFILE {
        DEFAULT_PROFILE.to_owned()
    } else {
        format!("{CONFIG_PROFILE_PREFIX}{profile}")
    };
    let config_section = config_ini.and_then(|ini| ini.section(Some(config_section_name.as_str())));

    // If the profile was explicitly requested but is absent from both files, report it early.
    if explicit_profile && cred_section.is_none() && config_section.is_none() {
        bail!(ProfileError::ProfileNotFound {
            name: profile.to_owned(),
            path: cred_path.to_owned(),
        });
    }

    let access_key = cli
        .access_key
        .map(str::to_owned)
        .or_else(|| {
            cred_section
                .and_then(|s| s.get(KEY_ACCESS_KEY_ID))
                .map(str::to_owned)
        })
        .ok_or_raise(|| ProfileError::MissingAccessKey)?;

    let secret_key = cli
        .secret_key
        .map(str::to_owned)
        .or_else(|| {
            cred_section
                .and_then(|s| s.get(KEY_SECRET_ACCESS_KEY))
                .map(str::to_owned)
        })
        .ok_or_raise(|| ProfileError::MissingSecretKey)?;

    let session_token = cli.session_token.map(str::to_owned).or_else(|| {
        cred_section
            .and_then(|s| s.get(KEY_SESSION_TOKEN))
            .map(str::to_owned)
            .or_else(|| {
                config_section
                    .and_then(|s| s.get(KEY_SESSION_TOKEN))
                    .map(str::to_owned)
            })
    });

    let region = cli
        .region
        .map(str::to_owned)
        .or_else(|| {
            config_section
                .and_then(|s| s.get(KEY_REGION))
                .map(str::to_owned)
        })
        .unwrap_or_else(|| DEFAULT_REGION.to_owned());

    Ok(ResolvedCredentials {
        access_key,
        secret_key,
        session_token,
        region,
    })
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(clippy::expect_used, reason = "test code")]
mod tests {
    use insta::assert_debug_snapshot;

    use super::*;

    /// Parses a literal INI string for use in tests.
    fn ini(s: &str) -> Ini {
        Ini::load_from_str(s).expect("test INI must be valid")
    }

    // ── resolve_from_ini ─────────────────────────────────────────────────────

    #[test]
    fn default_profile_from_credentials_only() {
        let cred = ini("[default]\naws_access_key_id = AKIA\naws_secret_access_key = secret");
        let result = resolve_from_ini(
            Some(&cred),
            None,
            DEFAULT_PROFILE,
            false,
            Path::new("~/.aws/credentials"),
            CliInput {
                access_key: None,
                secret_key: None,
                session_token: None,
                region: None,
            },
        )
        .expect("resolve should succeed");
        assert_debug_snapshot!(result, @r#"
        ResolvedCredentials {
            access_key: "AKIA",
            secret_key: "secret",
            session_token: None,
            region: "us-east-1",
        }
        "#);
    }

    #[test]
    fn region_from_config_overrides_default() {
        let cred = ini("[default]\naws_access_key_id = AKIA\naws_secret_access_key = secret");
        let conf = ini("[default]\nregion = eu-west-3");
        let result = resolve_from_ini(
            Some(&cred),
            Some(&conf),
            DEFAULT_PROFILE,
            false,
            Path::new("~/.aws/credentials"),
            CliInput {
                access_key: None,
                secret_key: None,
                session_token: None,
                region: None,
            },
        )
        .expect("resolve should succeed");
        assert_debug_snapshot!(result, @r#"
        ResolvedCredentials {
            access_key: "AKIA",
            secret_key: "secret",
            session_token: None,
            region: "eu-west-3",
        }
        "#);
    }

    #[test]
    fn named_profile_credentials_and_config() {
        let cred = ini("[myprofile]\naws_access_key_id = AKIB\naws_secret_access_key = sec2");
        let conf = ini("[profile myprofile]\nregion = ap-southeast-1");
        let result = resolve_from_ini(
            Some(&cred),
            Some(&conf),
            "myprofile",
            true,
            Path::new("~/.aws/credentials"),
            CliInput {
                access_key: None,
                secret_key: None,
                session_token: None,
                region: None,
            },
        )
        .expect("resolve should succeed");
        assert_debug_snapshot!(result, @r#"
        ResolvedCredentials {
            access_key: "AKIB",
            secret_key: "sec2",
            session_token: None,
            region: "ap-southeast-1",
        }
        "#);
    }

    #[test]
    fn cli_access_key_overrides_profile() {
        let cred = ini("[default]\naws_access_key_id = FROM_FILE\naws_secret_access_key = secret");
        let result = resolve_from_ini(
            Some(&cred),
            None,
            DEFAULT_PROFILE,
            false,
            Path::new("~/.aws/credentials"),
            CliInput {
                access_key: Some("FROM_CLI"),
                secret_key: None,
                session_token: None,
                region: None,
            },
        )
        .expect("resolve should succeed");
        assert_eq!(result.access_key, "FROM_CLI");
    }

    #[test]
    fn cli_region_overrides_config() {
        let cred = ini("[default]\naws_access_key_id = AKIA\naws_secret_access_key = secret");
        let conf = ini("[default]\nregion = eu-west-1");
        let result = resolve_from_ini(
            Some(&cred),
            Some(&conf),
            DEFAULT_PROFILE,
            false,
            Path::new("~/.aws/credentials"),
            CliInput {
                access_key: None,
                secret_key: None,
                session_token: None,
                region: Some("us-west-2"),
            },
        )
        .expect("resolve should succeed");
        assert_eq!(result.region, "us-west-2");
    }

    #[test]
    fn session_token_from_credentials_file() {
        let cred = ini(
            "[default]\naws_access_key_id = AKIA\naws_secret_access_key = secret\naws_session_token = tok123",
        );
        let result = resolve_from_ini(
            Some(&cred),
            None,
            DEFAULT_PROFILE,
            false,
            Path::new("~/.aws/credentials"),
            CliInput {
                access_key: None,
                secret_key: None,
                session_token: None,
                region: None,
            },
        )
        .expect("resolve should succeed");
        assert_debug_snapshot!(result, @r#"
        ResolvedCredentials {
            access_key: "AKIA",
            secret_key: "secret",
            session_token: Some(
                "tok123",
            ),
            region: "us-east-1",
        }
        "#);
    }

    #[test]
    fn missing_profile_returns_profile_not_found() {
        let cred = ini("[default]\naws_access_key_id = AKIA\naws_secret_access_key = secret");
        let err = resolve_from_ini(
            Some(&cred),
            None,
            "nonexistent",
            true,
            Path::new("/home/user/.aws/credentials"),
            CliInput {
                access_key: None,
                secret_key: None,
                session_token: None,
                region: None,
            },
        )
        .expect_err("should fail");
        assert_debug_snapshot!(*err, @r#"
        ProfileNotFound {
            name: "nonexistent",
            path: "/home/user/.aws/credentials",
        }
        "#);
    }

    #[test]
    fn missing_secret_key_returns_error() {
        let cred = ini("[default]\naws_access_key_id = AKIA");
        let err = resolve_from_ini(
            Some(&cred),
            None,
            DEFAULT_PROFILE,
            false,
            Path::new("~/.aws/credentials"),
            CliInput {
                access_key: None,
                secret_key: None,
                session_token: None,
                region: None,
            },
        )
        .expect_err("should fail");
        assert_debug_snapshot!(*err, @"MissingSecretKey");
    }

    #[test]
    fn no_files_no_cli_returns_missing_access_key() {
        let err = resolve_from_ini(
            None,
            None,
            DEFAULT_PROFILE,
            false,
            Path::new("~/.aws/credentials"),
            CliInput {
                access_key: None,
                secret_key: None,
                session_token: None,
                region: None,
            },
        )
        .expect_err("should fail");
        assert_debug_snapshot!(*err, @"MissingAccessKey");
    }
}
