%% ====================================================================
%% @author Gavin M. Roy <gavinmroy@gmail.com>
%% @copyright 2016, Gavin M. Roy
%% @copyright 2007-2024 Broadcom. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved.
%% @private
%% @doc rabbitmq_aws configuration functionality
%% @end
%% ====================================================================
-module(rabbitmq_aws_config).

%% API
-export([
    credentials/0,
    credentials/1,
    value/2,
    values/1,
    instance_metadata_url/1,
    instance_credentials_url/1,
    instance_availability_zone_url/0,
    instance_role_url/0,
    instance_id_url/0,
    instance_id/0,
    load_imdsv2_token/0,
    instance_metadata_request_headers/0,
    region/0,
    region/1
]).

%% Export all for unit tests
-ifdef(TEST).
-compile(export_all).
-endif.

-include("rabbitmq_aws.hrl").
-include_lib("kernel/include/logger.hrl").

-spec credentials() -> security_credentials().
%% @doc Return the credentials from environment variables, configuration or the
%%      EC2 local instance metadata server, if available.
%%
%%      If the ``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` environment
%%      variables are set, those values will be returned. If they are not, the
%%      local configuration file or shared credentials file will be consulted.
%%      If either exists and can be checked, they will attempt to return the
%%      authentication credential values for the ``default`` profile if the
%%      ``AWS_DEFAULT_PROFILE`` environment is not set.
%%
%%      When checking for the configuration file, it will attempt to read the
%%      file from ``~/.aws/config`` if the ``AWS_CONFIG_FILE`` environment
%%      variable is not set. If the file is found, and both the access key and
%%      secret access key are set for the profile, they will be returned. If not
%%      it will attempt to consult the shared credentials file.
%%
%%      When checking for the shared credentials file, it will attempt to read
%%      read from ``~/.aws/credentials`` if the ``AWS_SHARED_CREDENTIALS_FILE``
%%      environment variable is not set. If the file is found and the both the
%%      access key and the secret access key are set for the profile, they will
%%      be returned.
%%
%%      If credentials are returned at any point up through this stage, they
%%      will be returned as ``{ok, AccessKey, SecretKey, undefined}``,
%%      indicating the credentials are locally configured, and are not
%%      temporary.
%%
%%      If no credentials could be resolved up until this point, there will be
%%      an attempt to contact a local EC2 instance metadata service for
%%      credentials.
%%
%%      When the EC2 instance metadata server is checked for but does not exist,
%%      the operation will timeout in ``?DEFAULT_HTTP_TIMEOUT``ms.
%%
%%      When the EC2 instance metadata server exists, but data is not returned
%%      quickly, the operation will timeout in ``?DEFAULT_HTTP_TIMEOUT``ms.
%%
%%      If the service does exist, it will attempt to use the
%%      ``/meta-data/iam/security-credentials`` endpoint to request expiring
%%      request credentials to use. If they are found, a tuple of
%%      ``{ok, AccessKey, SecretAccessKey, SecurityToken}`` will be returned
%%      indicating the credentials are temporary and require the use of the
%%      ``X-Amz-Security-Token`` header should be used.
%%
%%      Finally, if no credentials are found by this point, an error tuple
%%      will be returned.
%% @end
credentials() ->
    credentials(profile()).

-spec credentials(string()) -> security_credentials().
%% @doc Return the credentials from environment variables, configuration or the
%%      EC2 local instance metadata server, if available.
%%
%%      If the ``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` environment
%%      variables are set, those values will be returned. If they are not, the
%%      local configuration file or shared credentials file will be consulted.
%%
%%      When checking for the configuration file, it will attempt to read the
%%      file from ``~/.aws/config`` if the ``AWS_CONFIG_FILE`` environment
%%      variable is not set. If the file is found, and both the access key and
%%      secret access key are set for the profile, they will be returned. If not
%%      it will attempt to consult the shared credentials file.
%%
%%      When checking for the shared credentials file, it will attempt to read
%%      read from ``~/.aws/credentials`` if the ``AWS_SHARED_CREDENTIALS_FILE``
%%      environment variable is not set. If the file is found and the both the
%%      access key and the secret access key are set for the profile, they will
%%      be returned.
%%
%%      If credentials are returned at any point up through this stage, they
%%      will be returned as ``{ok, AccessKey, SecretKey, undefined}``,
%%      indicating the credentials are locally configured, and are not
%%      temporary.
%%
%%      If no credentials could be resolved up until this point, there will be
%%      an attempt to contact a local EC2 instance metadata service for
%%      credentials.
%%
%%      When the EC2 instance metadata server is checked for but does not exist,
%%      the operation will timeout in ``?DEFAULT_HTTP_TIMEOUT``ms.
%%
%%      When the EC2 instance metadata server exists, but data is not returned
%%      quickly, the operation will timeout in ``?DEFAULT_HTTP_TIMEOUT``ms.
%%
%%      If the service does exist, it will attempt to use the
%%      ``/meta-data/iam/security-credentials`` endpoint to request expiring
%%      request credentials to use. If they are found, a tuple of
%%      ``{ok, AccessKey, SecretAccessKey, SecurityToken}`` will be returned
%%      indicating the credentials are temporary and require the use of the
%%      ``X-Amz-Security-Token`` header should be used.
%%
%%      Finally, if no credentials are found by this point, an error tuple
%%      will be returned.
%% @end
credentials(Profile) ->
    lookup_credentials(
        Profile,
        os:getenv("AWS_ACCESS_KEY_ID"),
        os:getenv("AWS_SECRET_ACCESS_KEY"),
        os:getenv("AWS_SESSION_TOKEN")
    ).

-spec region() -> {ok, string()}.
%% @doc Return the region as configured by ``AWS_DEFAULT_REGION`` environment
%%      variable or as configured in the configuration file using the default
%%      profile or configured ``AWS_DEFAULT_PROFILE`` environment variable.
%%
%%      If the environment variable is not set and a configuration
%%      file is not found, it will try and return the region from the EC2
%%      local instance metadata server.
%% @end
region() ->
    region(profile()).

-spec region(Region :: string()) -> {ok, region()}.
%% @doc Return the region as configured by ``AWS_DEFAULT_REGION`` environment
%%      variable or as configured in the configuration file using the specified
%%      profile.
%%
%%      If the environment variable is not set and a configuration
%%      file is not found, it will try and return the region from the EC2
%%      local instance metadata server.
%% @end
region(Profile) ->
    case lookup_region(Profile, os:getenv("AWS_DEFAULT_REGION")) of
        {ok, Region} -> {ok, Region};
        _ -> {ok, ?DEFAULT_REGION}
    end.

-spec instance_id() -> {'ok', string()} | {'error', 'undefined'}.
%% @doc Return the instance ID from the EC2 metadata service.
%% @end
instance_id() ->
    URL = instance_id_url(),
    parse_body_response(perform_http_get_instance_metadata(URL)).

-spec value(Profile :: string(), Key :: atom()) ->
    Value :: any() | {error, Reason :: atom()}.
%% @doc Return the configuration data for the specified profile or an error
%%      if the profile is not found.
%% @end
value(Profile, Key) ->
    get_value(Key, values(Profile)).

-spec values(Profile :: string()) ->
    Settings ::
        list()
        | {error, Reason :: atom()}.
%% @doc Return the configuration data for the specified profile or an error
%%      if the profile is not found.
%% @end
values(Profile) ->
    case config_file_data() of
        {error, Reason} ->
            {error, Reason};
        Settings ->
            Prefixed = lists:flatten(["profile ", Profile]),
            proplists:get_value(
                Profile,
                Settings,
                proplists:get_value(
                    Prefixed,
                    Settings,
                    {error, undefined}
                )
            )
    end.

%% -----------------------------------------------------------------------------
%% Private / Internal Methods
%% -----------------------------------------------------------------------------

-spec config_file() -> string().
%% @doc Return the configuration file to test using either the value of the
%%      AWS_CONFIG_FILE or the default location where the file is expected to
%%      exist.
%% @end
config_file() ->
    config_file(os:getenv("AWS_CONFIG_FILE")).

-spec config_file(Path :: false | string()) -> string().
%% @doc Return the configuration file to test using either the value of the
%%      AWS_CONFIG_FILE or the default location where the file is expected to
%%      exist.
%% @end
config_file(false) ->
    filename:join([home_path(), ".aws", "config"]);
config_file(EnvVar) ->
    EnvVar.

-spec config_file_data() -> list() | {error, Reason :: atom()}.
%% @doc Return the values from a configuration file as a proplist by section
%% @end
config_file_data() ->
    ini_file_data(config_file()).

-spec credentials_file() -> string().
%% @doc Return the shared credentials file to test using either the value of the
%%      AWS_SHARED_CREDENTIALS_FILE or the default location where the file
%%      is expected to exist.
%% @end
credentials_file() ->
    credentials_file(os:getenv("AWS_SHARED_CREDENTIALS_FILE")).

-spec credentials_file(Path :: false | string()) -> string().
%% @doc Return the shared credentials file to test using either the value of the
%%      AWS_SHARED_CREDENTIALS_FILE or the default location where the file
%%      is expected to exist.
%% @end
credentials_file(false) ->
    filename:join([home_path(), ".aws", "credentials"]);
credentials_file(EnvVar) ->
    EnvVar.

-spec credentials_file_data() -> list() | {error, Reason :: atom()}.
%% @doc Return the values from a configuration file as a proplist by section
%% @end
credentials_file_data() ->
    ini_file_data(credentials_file()).

-spec get_value
    (Key :: atom(), Settings :: list()) -> any();
    (Key :: atom(), {error, Reason :: atom()}) -> {error, Reason :: atom()}.
%% @doc Get the value for a key from a settings proplist.
%% @end
get_value(Key, Settings) when is_list(Settings) ->
    proplists:get_value(Key, Settings, {error, undefined});
get_value(_, {error, Reason}) ->
    {error, Reason}.

-spec home_path() -> string().
%% @doc Return the path to the current user's home directory, checking for the
%%      HOME environment variable before returning the current working
%%      directory if it's not set.
%% @end
home_path() ->
    home_path(os:getenv("HOME")).

-spec home_path(Value :: string() | false) -> string().
%% @doc Return the path to the current user's home directory, checking for the
%%      HOME environment variable before returning the current working
%%      directory if it's not set.
%% @end
home_path(false) -> filename:absname(".");
home_path(Value) -> Value.

-spec ini_file_data(Path :: string()) ->
    list() | {error, atom()}.
%% @doc Return the parsed ini file for the specified path.
%% @end
ini_file_data(Path) ->
    ini_file_data(Path, filelib:is_file(Path)).

-spec ini_file_data(Path :: string(), FileExists :: boolean()) ->
    list() | {error, atom()}.
%% @doc Return the parsed ini file for the specified path.
%% @end
ini_file_data(Path, true) ->
    case read_file(Path) of
        {ok, Lines} -> ini_parse_lines(Lines, none, none, []);
        {error, Reason} -> {error, Reason}
    end;
ini_file_data(_, false) ->
    {error, enoent}.

-spec ini_format_key(any()) -> atom() | {error, type}.
%% @doc Converts a ini file key to an atom, stripping any leading whitespace
%% @end
ini_format_key(Key) ->
    case io_lib:printable_list(Key) of
        true -> list_to_atom(string:strip(Key));
        false -> {error, type}
    end.

-spec ini_parse_line(
    Section :: list(),
    Key :: atom(),
    Line :: binary()
) ->
    {Section :: list(), Key :: string() | none}.
%% @doc Parse the AWS configuration INI file, returning a proplist
%% @end
ini_parse_line(Section, Parent, <<" ", Line/binary>>) ->
    Child = proplists:get_value(Parent, Section, []),
    {ok, NewChild} = ini_parse_line_parts(Child, ini_split_line(Line)),
    {lists:keystore(Parent, 1, Section, {Parent, NewChild}), Parent};
ini_parse_line(Section, _, Line) ->
    case ini_parse_line_parts(Section, ini_split_line(Line)) of
        {ok, NewSection} -> {NewSection, none};
        {new_parent, Parent} -> {Section, Parent}
    end.

-spec ini_parse_line_parts(
    Section :: list(),
    Parts :: list()
) ->
    {ok, list()} | {new_parent, atom()}.
%% @doc Parse the AWS configuration INI file, returning a proplist
%% @end
ini_parse_line_parts(Section, []) ->
    {ok, Section};
ini_parse_line_parts(Section, [RawKey, Value]) ->
    Key = ini_format_key(RawKey),
    {ok, lists:keystore(Key, 1, Section, {Key, maybe_convert_number(Value)})};
ini_parse_line_parts(_, [RawKey]) ->
    {new_parent, ini_format_key(RawKey)}.

-spec ini_parse_lines(
    Lines :: [binary()],
    SectionName :: string() | atom(),
    Parent :: atom(),
    Accumulator :: list()
) ->
    list().
%% @doc Parse the AWS configuration INI file
%% @end
ini_parse_lines([], _, _, Settings) ->
    Settings;
ini_parse_lines([H | T], SectionName, Parent, Settings) ->
    {ok, NewSectionName} = ini_parse_section_name(SectionName, H),
    {ok, NewParent, NewSettings} = ini_parse_section(
        H,
        NewSectionName,
        Parent,
        Settings
    ),
    ini_parse_lines(T, NewSectionName, NewParent, NewSettings).

-spec ini_parse_section(
    Line :: binary(),
    SectionName :: string(),
    Parent :: atom(),
    Section :: list()
) ->
    {ok, NewParent :: atom(), Section :: list()}.
%% @doc Parse a line from the ini file, returning it as part of the appropriate
%%      section.
%% @end
ini_parse_section(Line, SectionName, Parent, Settings) ->
    Section = proplists:get_value(SectionName, Settings, []),
    {NewSection, NewParent} = ini_parse_line(Section, Parent, Line),
    {ok, NewParent,
        lists:keystore(
            SectionName,
            1,
            Settings,
            {SectionName, NewSection}
        )}.

-spec ini_parse_section_name(
    CurrentSection :: string() | atom(),
    Line :: binary()
) ->
    {ok, SectionName :: string()}.
%% @doc Attempts to parse a section name from the current line, returning either
%%      the new parsed section name, or the current section name.
%% @end
ini_parse_section_name(CurrentSection, Line) ->
    Value = binary_to_list(Line),
    case re:run(Value, "\\[([\\w\\s+\\-_]+)\\]", [{capture, all, list}]) of
        {match, [_, SectionName]} -> {ok, SectionName};
        nomatch -> {ok, CurrentSection}
    end.

-spec ini_split_line(binary()) -> list().
%% @doc Split a key value pair delimited by ``=`` to a list of strings.
%% @end
ini_split_line(Line) ->
    string:tokens(string:strip(binary_to_list(Line)), "=").

-spec instance_availability_zone_url() -> string().
%% @doc Return the URL for querying the availability zone from the Instance
%%      Metadata service
%% @end
instance_availability_zone_url() ->
    instance_metadata_url(string:join([?INSTANCE_METADATA_BASE, ?INSTANCE_AZ], "/")).

-spec instance_credentials_url(string()) -> string().
%% @doc Return the URL for querying temporary credentials from the Instance
%%      Metadata service for the specified role
%% @end
instance_credentials_url(Role) ->
    instance_metadata_url(string:join([?INSTANCE_METADATA_BASE, ?INSTANCE_CREDENTIALS, Role], "/")).

-spec instance_metadata_host() -> string().
%% @doc Return the appropriate instance metadata host based on IP family configuration
%% @end
instance_metadata_host() ->
    case proplists:get_value(inet6, inet:get_rc(), false) of
        true -> ?INSTANCE_HOST_6;
        false -> ?INSTANCE_HOST
    end.

-spec instance_metadata_url(string()) -> string().
%% @doc Build the Instance Metadata service URL for the specified path
%% @end
instance_metadata_url(Path) ->
    rabbitmq_aws_urilib:build(#uri{
        scheme = http,
        authority = {undefined, instance_metadata_host(), undefined},
        path = Path,
        query = []
    }).

-spec instance_role_url() -> string().
%% @doc Return the URL for querying the role associated with the current
%%      instance from the Instance Metadata service
%% @end
instance_role_url() ->
    instance_metadata_url(string:join([?INSTANCE_METADATA_BASE, ?INSTANCE_CREDENTIALS], "/")).

-spec imdsv2_token_url() -> string().
%% @doc Return the URL for obtaining EC2 IMDSv2 token from the Instance Metadata service.
%% @end
imdsv2_token_url() ->
    instance_metadata_url(?TOKEN_URL).

-spec instance_id_url() -> string().
%% @doc Return the URL for querying the id of the current instance from the Instance Metadata service.
%% @end
instance_id_url() ->
    instance_metadata_url(string:join([?INSTANCE_METADATA_BASE, ?INSTANCE_ID], "/")).

-spec lookup_credentials(
    Profile :: string(),
    AccessKey :: string() | false,
    SecretKey :: string() | false,
    SessionToken :: string() | false
) ->
    security_credentials().
%% @doc Return the access key and secret access key if they are set in
%%      environment variables, otherwise lookup the credentials from the config
%%      file for the specified profile.
%% @end
lookup_credentials(Profile, false, _, _) ->
    lookup_credentials_from_config(
        Profile,
        value(Profile, aws_access_key_id),
        value(Profile, aws_secret_access_key),
        value(Profile, aws_session_token)
    );
lookup_credentials(Profile, _, false, _) ->
    lookup_credentials_from_config(
        Profile,
        value(Profile, aws_access_key_id),
        value(Profile, aws_secret_access_key),
        value(Profile, aws_session_token)
    );
lookup_credentials(_, AccessKey, SecretKey, SessionToken) ->
    case SessionToken of
        false -> {ok, AccessKey, SecretKey, undefined, undefined};
        SessionToken -> {ok, AccessKey, SecretKey, undefined, SessionToken}
    end.

-spec lookup_credentials_from_config(
    Profile :: string(),
    access_key() | {error, Reason :: atom()},
    secret_access_key() | {error, Reason :: atom()},
    security_token() | {error, Reason :: atom()}
) ->
    security_credentials().
%% @doc Return the access key and secret access key if they are set in
%%      for the specified profile in the config file, if it exists. If it does
%%      not exist or the profile is not set or the values are not set in the
%%      profile, look up the values in the shared credentials file
%% @end
lookup_credentials_from_config(Profile, {error, _}, _, _) ->
    lookup_credentials_from_file(Profile, credentials_file_data());
lookup_credentials_from_config(_, AccessKey, SecretKey, SessionToken) ->
    case SessionToken of
        {error, _} -> {ok, AccessKey, SecretKey, undefined, undefined};
        SessionToken -> {ok, AccessKey, SecretKey, undefined, SessionToken}
    end.

-spec lookup_credentials_from_file(
    Profile :: string(),
    Credentials :: list()
) ->
    security_credentials().
%% @doc Check to see if the shared credentials file exists and if it does,
%%      invoke ``lookup_credentials_from_shared_creds_section/2`` to attempt to
%%      get the credentials values out of it. If the file does not exist,
%%      attempt to lookup the values from the EC2 instance metadata service.
%% @end
lookup_credentials_from_file(_, {error, _}) ->
    lookup_credentials_from_instance_metadata();
lookup_credentials_from_file(Profile, Credentials) ->
    Section = proplists:get_value(Profile, Credentials),
    lookup_credentials_from_section(Section).

-spec lookup_credentials_from_section(Credentials :: list() | undefined) ->
    security_credentials().
%% @doc Return the access key and secret access key if they are set in
%%      for the specified profile from the shared credentials file. If the
%%      profile is not set or the values are not set in the profile, attempt to
%%      lookup the values from the EC2 instance metadata service.
%% @end
lookup_credentials_from_section(undefined) ->
    lookup_credentials_from_instance_metadata();
lookup_credentials_from_section(Credentials) ->
    AccessKey = proplists:get_value(aws_access_key_id, Credentials, undefined),
    SecretKey = proplists:get_value(aws_secret_access_key, Credentials, undefined),
    SessionToken = proplists:get_value(aws_session_token, Credentials, undefined),
    lookup_credentials_from_proplist(AccessKey, SecretKey, SessionToken).

-spec lookup_credentials_from_proplist(
    AccessKey :: access_key(),
    SecretAccessKey :: secret_access_key(),
    SessionToken :: security_token()
) ->
    security_credentials().
%% @doc Process the contents of the Credentials proplists checking if the
%%      access key and secret access key are both set.
%% @end
lookup_credentials_from_proplist(undefined, _, _) ->
    lookup_credentials_from_instance_metadata();
lookup_credentials_from_proplist(_, undefined, _) ->
    lookup_credentials_from_instance_metadata();
lookup_credentials_from_proplist(AccessKey, SecretKey, SessionToken) ->
    {ok, AccessKey, SecretKey, undefined, SessionToken}.

-spec lookup_credentials_from_instance_metadata() ->
    security_credentials().
%% @spec lookup_credentials_from_instance_metadata() -> Result.
%% @doc Attempt to lookup the values from the EC2 instance metadata service.
%% @end
lookup_credentials_from_instance_metadata() ->
    Role = maybe_get_role_from_instance_metadata(),
    maybe_get_credentials_from_instance_metadata(Role).

-spec lookup_region(
    Profile :: string(),
    Region :: false | string()
) ->
    {ok, string()} | {error, undefined}.
%% @doc If Region is false, lookup the region from the config or the EC2
%%      instance metadata service.
%% @end
lookup_region(Profile, false) ->
    lookup_region_from_config(values(Profile));
lookup_region(_, Region) ->
    {ok, Region}.

-spec lookup_region_from_config(Settings :: list() | {error, atom()}) ->
    {ok, string()} | {error, undefined}.
%% @doc Return the region from the local configuration file. If local config
%%      settings are not found, try to lookup the region from the EC2 instance
%%      metadata service.
%% @end
lookup_region_from_config({error, _}) ->
    maybe_get_region_from_instance_metadata();
lookup_region_from_config(Settings) ->
    lookup_region_from_settings(proplists:get_value(region, Settings)).

-spec lookup_region_from_settings(any() | undefined) ->
    {ok, string()} | {error, undefined}.
%% @doc Decide if the region should be loaded from the Instance Metadata service
%%      of if it's already set.
%% @end
lookup_region_from_settings(undefined) ->
    maybe_get_region_from_instance_metadata();
lookup_region_from_settings(Region) ->
    {ok, Region}.

-spec maybe_convert_number(string()) -> integer() | float().
%% @doc Returns an integer or float from a string if possible, otherwise
%%      returns the string().
%% @end
maybe_convert_number(Value) ->
    Stripped = string:strip(Value),
    case string:to_float(Stripped) of
        {error, no_float} ->
            try
                list_to_integer(Stripped)
            catch
                error:badarg -> Stripped
            end;
        {F, _Rest} ->
            F
    end.

-spec maybe_get_credentials_from_instance_metadata(
    {ok, Role :: string()}
    | {error, undefined}
) ->
    {'ok', security_credentials()} | {'error', term()}.
%% @doc Try to query the EC2 local instance metadata service to get temporary
%%      authentication credentials.
%% @end
maybe_get_credentials_from_instance_metadata({error, undefined}) ->
    {error, undefined};
maybe_get_credentials_from_instance_metadata({ok, Role}) ->
    URL = instance_credentials_url(Role),
    parse_credentials_response(perform_http_get_instance_metadata(URL)).

-spec maybe_get_region_from_instance_metadata() ->
    {ok, Region :: string()} | {error, Reason :: atom()}.
%% @doc Try to query the EC2 local instance metadata service to get the region
%% @end
maybe_get_region_from_instance_metadata() ->
    URL = instance_availability_zone_url(),
    parse_az_response(perform_http_get_instance_metadata(URL)).

%% @doc Try to query the EC2 local instance metadata service to get the role
%%      assigned to the instance.
%% @end
maybe_get_role_from_instance_metadata() ->
    URL = instance_role_url(),
    parse_body_response(perform_http_get_instance_metadata(URL)).

-spec parse_az_response(httpc_result()) ->
    {ok, Region :: string()} | {error, Reason :: atom()}.
%% @doc Parse the response from the Availability Zone query to the
%%      Instance Metadata service, returning the Region if successful.
%% end.
parse_az_response({error, _}) -> {error, undefined};
parse_az_response({ok, {{_, 200, _}, _, Body}}) -> {ok, region_from_availability_zone(Body)};
parse_az_response({ok, {{_, _, _}, _, _}}) -> {error, undefined}.

-spec parse_body_response(httpc_result()) ->
    {ok, Value :: string()} | {error, Reason :: atom()}.
%% @doc Parse the return response from the Instance Metadata Service where the
%%      body value is the string to process.
%% end.
parse_body_response({error, _}) ->
    {error, undefined};
parse_body_response({ok, {{_, 200, _}, _, Body}}) ->
    {ok, Body};
parse_body_response({ok, {{_, 401, _}, _, _}}) ->
    ?LOG_ERROR(
        get_instruction_on_instance_metadata_error(
            "Unauthorized instance metadata service request."
        )
    ),
    {error, undefined};
parse_body_response({ok, {{_, 403, _}, _, _}}) ->
    ?LOG_ERROR(
        get_instruction_on_instance_metadata_error(
            "The request is not allowed or the instance metadata service is turned off."
        )
    ),
    {error, undefined};
parse_body_response({ok, {{_, _, _}, _, _}}) ->
    {error, undefined}.

-spec parse_credentials_response(httpc_result()) -> security_credentials().
%% @doc Try to query the EC2 local instance metadata service to get the role
%%      assigned to the instance.
%% @end
parse_credentials_response({error, _}) ->
    {error, undefined};
parse_credentials_response({ok, {{_, 404, _}, _, _}}) ->
    {error, undefined};
parse_credentials_response({ok, {{_, 200, _}, _, Body}}) ->
    Parsed = rabbitmq_aws_json:decode(Body),
    {ok, proplists:get_value("AccessKeyId", Parsed), proplists:get_value("SecretAccessKey", Parsed),
        parse_iso8601_timestamp(proplists:get_value("Expiration", Parsed)),
        proplists:get_value("Token", Parsed)}.

-spec perform_http_get_instance_metadata(string()) -> httpc_result().
%% @doc Wrap httpc:get/4 to simplify Instance Metadata service v2 requests
%% @end
perform_http_get_instance_metadata(URL) ->
    ?LOG_DEBUG("Querying instance metadata service: ~tp", [URL]),
    httpc:request(
        get,
        {URL, instance_metadata_request_headers()},
        [{timeout, ?DEFAULT_HTTP_TIMEOUT}],
        []
    ).

-spec get_instruction_on_instance_metadata_error(string()) -> string().
%% @doc Return error message on failures related to EC2 Instance Metadata Service with a reference to AWS document.
%% end
get_instruction_on_instance_metadata_error(ErrorMessage) ->
    ErrorMessage ++
        " Please refer to the AWS documentation for details on how to configure the instance metadata service: "
        "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html.".

-spec parse_iso8601_timestamp(Timestamp :: string() | binary()) -> calendar:datetime().
%% @doc Parse a ISO8601 timestamp, returning a datetime() value.
%% @end
parse_iso8601_timestamp(Timestamp) when is_binary(Timestamp) ->
    parse_iso8601_timestamp(binary_to_list(Timestamp));
parse_iso8601_timestamp(Timestamp) ->
    [Date, Time] = string:tokens(Timestamp, "T"),
    [Year, Month, Day] = string:tokens(Date, "-"),
    [Hour, Minute, Second] = string:tokens(Time, ":"),
    {{list_to_integer(Year), list_to_integer(Month), list_to_integer(Day)}, {
        list_to_integer(Hour), list_to_integer(Minute), list_to_integer(string:left(Second, 2))
    }}.

-spec profile() -> string().
%% @doc Return the value of the AWS_DEFAULT_PROFILE environment variable or the
%%      "default" profile.
%% @end
profile() -> profile(os:getenv("AWS_DEFAULT_PROFILE")).

-spec profile(false | string()) -> string().
%% @doc Process the value passed in to determine if we will return the default
%%      profile or the value from the environment variable.
%% @end
profile(false) -> ?DEFAULT_PROFILE;
profile(Value) -> Value.

-spec read_file(string()) -> {'ok', [binary()]} | {error, Reason :: atom()}.
%% @doc Read the specified file, returning the contents as a list of strings.
%% @end
read_file(Path) ->
    case file:read_file(Path) of
        {ok, Binary} ->
            {ok, re:split(Binary, <<"\r\n|\n">>, [{return, binary}])};
        {error, _} = Error ->
            Error
    end.

-spec region_from_availability_zone(Value :: string()) -> string().
%% @doc Strip the availability zone suffix from the region.
%% @end
region_from_availability_zone(Value) ->
    string:sub_string(Value, 1, length(Value) - 1).

-spec load_imdsv2_token() -> security_token().
%% @doc Attempt to obtain EC2 IMDSv2 token.
%% @end
load_imdsv2_token() ->
    TokenUrl = imdsv2_token_url(),
    ?LOG_INFO("Attempting to obtain EC2 IMDSv2 token from ~tp ...", [TokenUrl]),
    case
        httpc:request(
            put,
            {TokenUrl, [{?METADATA_TOKEN_TTL_HEADER, integer_to_list(?METADATA_TOKEN_TTL_SECONDS)}]},
            [{timeout, ?DEFAULT_HTTP_TIMEOUT}],
            []
        )
    of
        {ok, {{_, 200, _}, _, Value}} ->
            ?LOG_DEBUG("Successfully obtained EC2 IMDSv2 token."),
            Value;
        {error, {{_, 400, _}, _, _}} ->
            ?LOG_WARNING(
                "Failed to obtain EC2 IMDSv2 token: Missing or Invalid Parameters – The PUT request is not valid."
            ),
            undefined;
        Other ->
            ?LOG_WARNING(
                get_instruction_on_instance_metadata_error(
                    "Failed to obtain EC2 IMDSv2 token: ~tp. "
                    "Falling back to EC2 IMDSv1 for now. It is recommended to use EC2 IMDSv2."
                ),
                [Other]
            ),
            undefined
    end.

-spec instance_metadata_request_headers() -> headers().
%% @doc Return headers used for instance metadata service requests.
%% @end
instance_metadata_request_headers() ->
    case application:get_env(rabbitmq_aws, aws_prefer_imdsv2) of
        {ok, false} ->
            [];
        %% undefined or {ok, true}
        _ ->
            ?LOG_DEBUG("EC2 Instance Metadata Service v2 (IMDSv2) is preferred."),
            maybe_imdsv2_token_headers()
    end.

-spec maybe_imdsv2_token_headers() -> headers().
%% @doc Construct http request headers from Imdsv2Token to use with GET requests submitted to the EC2 Instance Metadata Service.
%% @end
maybe_imdsv2_token_headers() ->
    case rabbitmq_aws:ensure_imdsv2_token_valid() of
        undefined -> [];
        Value -> [{?METADATA_TOKEN, Value}]
    end.
