%%%-------------------------------------------------------------------
%%% @author
%%% @copyright (C) 2026, <COMPANY>
%%% @doc
%%% ContainerRequest wire builder helpers.
%%% @end
%%%-------------------------------------------------------------------
-module(docker_container_builder).

-export([list_request/0, config_request/2, deploy_request/2, start_request/1, stop_request/1, kill_request/1, remove_request/1]).

-spec list_request() -> map().
list_request() ->
    #{action => list, all => true}.

-spec config_request(binary(), binary()) -> map().
config_request(ContainerName, ConfigJson) when is_binary(ContainerName), is_binary(ConfigJson) ->
    #{
        action => config,
        target => container_ref(ContainerName),
        config => ConfigJson
    }.

-spec deploy_request(integer(), map()) -> {ok, map()} | {error, binary()}.
deploy_request(TaskId, Config) when is_integer(TaskId), is_map(Config), TaskId >= 0 ->
    try
        ensure_supported_deploy_config(Config),
        validate_deploy_config(Config),
        Params = build_container_deploy_params(Config),
        {ok, #{action => deploy, task_id => TaskId, params => Params}}
    catch
        throw:{error, Reason} ->
            {error, Reason}
    end;
deploy_request(TaskId, _Config) when is_integer(TaskId), TaskId < 0 ->
    {error, <<"task_id must be non-negative">>};
deploy_request(_TaskId, _Config) ->
    {error, <<"invalid deploy request">>}.

-spec start_request(binary()) -> map().
start_request(ContainerName) when is_binary(ContainerName) ->
    #{action => start, target => container_ref(ContainerName)}.

-spec stop_request(binary()) -> map().
stop_request(ContainerName) when is_binary(ContainerName) ->
    #{
        action => stop,
        target => container_ref(ContainerName),
        timeout_seconds => 0
    }.

-spec kill_request(binary()) -> map().
kill_request(ContainerName) when is_binary(ContainerName) ->
    #{
        action => kill,
        target => container_ref(ContainerName),
        signal => <<>>
    }.

-spec remove_request(binary()) -> map().
remove_request(ContainerName) when is_binary(ContainerName) ->
    #{
        action => remove,
        target => container_ref(ContainerName),
        force => false,
        remove_volumes => false
    }.

-spec container_ref(binary()) -> map().
container_ref(ContainerName) when is_binary(ContainerName) ->
    #{name => ContainerName, id => <<>>}.

-spec ensure_supported_deploy_config(map()) -> ok.
ensure_supported_deploy_config(Config) when is_map(Config) ->
    UnsupportedKeys = [Key || Key <- [<<"container_dir">>], maps:is_key(Key, Config)],
    case UnsupportedKeys of
        [] ->
            ok;
        _ ->
            Unsupported = iolist_to_binary(lists:join(<<", ">>, UnsupportedKeys)),
            throw({error, <<"unsupported container config keys: ", Unsupported/binary>>})
    end.

-spec validate_deploy_config(map()) -> ok.
validate_deploy_config(Config) when is_map(Config) ->
    Required = [
        {<<"image">>, binary},
        {<<"container_name">>, binary},
        {<<"command">>, {list, binary}},
        {<<"restart">>, binary}
    ],
    Optional = [
        {<<"privileged">>, boolean},
        {<<"entrypoint">>, {list, binary}},
        {<<"envs">>, {list, binary}},
        {<<"ports">>, {list, binary}},
        {<<"expose">>, {list, binary}},
        {<<"volumes">>, {list, binary}},
        {<<"networks">>, {list, binary}},
        {<<"labels">>, {map, {binary, binary}}},
        {<<"user">>, binary},
        {<<"working_dir">>, binary},
        {<<"hostname">>, binary},
        {<<"network_mode">>, binary},
        {<<"cap_add">>, {list, binary}},
        {<<"cap_drop">>, {list, binary}},
        {<<"devices">>, {list, binary}},
        {<<"mem_limit">>, binary},
        {<<"mem_reservation">>, binary},
        {<<"cpu_shares">>, non_neg_integer},
        {<<"cpus">>, non_neg_number},
        {<<"ulimits">>, {map, {binary, binary}}},
        {<<"sysctls">>, {map, {binary, binary}}},
        {<<"tmpfs">>, {list, binary}},
        {<<"extra_hosts">>, {list, binary}},
        {<<"healthcheck">>, map}
    ],
    Errors = check_required(Config, Required) ++ check_optional(Config, Optional) ++ check_healthcheck(Config),
    case Errors of
        [] ->
            ok;
        _ ->
            throw({error, iolist_to_binary(lists:join(<<"|||">>, Errors))})
    end.

-spec check_required(map(), list()) -> [binary()].
check_required(Config, Fields) ->
    lists:foldl(
        fun({Key, Type}, ErrAcc) ->
            case maps:get(Key, Config, undefined) of
                undefined ->
                    [iolist_to_binary(io_lib:format("miss requied parameter: ~p", [Key])) | ErrAcc];
                Value ->
                    case check_type(Value, Type) of
                        true ->
                            ErrAcc;
                        false ->
                            [iolist_to_binary(io_lib:format("required parameter: ~p, type must be: ~ts", [Key, type_name(Type)])) | ErrAcc]
                    end
            end
        end,
        [], Fields).

-spec check_optional(map(), list()) -> [binary()].
check_optional(Config, Fields) ->
    lists:foldl(
        fun({Key, Type}, ErrAcc) ->
            case maps:get(Key, Config, undefined) of
                undefined ->
                    ErrAcc;
                Value ->
                    case check_type(Value, Type) of
                        true ->
                            ErrAcc;
                        false ->
                            [iolist_to_binary(io_lib:format("optional parameter: ~p, type must be: ~ts", [Key, type_name(Type)])) | ErrAcc]
                    end
            end
        end,
        [], Fields).

-spec type_name(tuple() | atom()) -> binary().
type_name(binary) ->
    <<"string">>;
type_name(integer) ->
    <<"integer">>;
type_name(non_neg_integer) ->
    <<"non-negative integer">>;
type_name(number) ->
    <<"number">>;
type_name(non_neg_number) ->
    <<"non-negative number">>;
type_name(duration) ->
    <<"duration string or non-negative integer">>;
type_name(list) ->
    <<"list">>;
type_name({list, binary}) ->
    <<"list of string">>;
type_name({list, number}) ->
    <<"list of number">>;
type_name({list, integer}) ->
    <<"list of integer">>;
type_name(map) ->
    <<"map">>;
type_name({map, {binary, binary}}) ->
    <<"map of string:string">>;
type_name({map, {binary, any}}) ->
    <<"map of string:any">>;
type_name(boolean) ->
    <<"boolean">>.

-spec check_type(any(), any()) -> boolean().
check_type(Value, binary) ->
    is_binary(Value);
check_type(Value, integer) ->
    is_integer(Value);
check_type(Value, non_neg_integer) ->
    is_integer(Value) andalso Value >= 0;
check_type(Value, number) ->
    is_number(Value);
check_type(Value, non_neg_number) ->
    is_number(Value) andalso Value >= 0;
check_type(Value, duration) ->
    is_binary(Value) orelse (is_integer(Value) andalso Value >= 0);
check_type(Value, list) when is_list(Value) ->
    true;
check_type(Value, {list, binary}) when is_list(Value) ->
    lists:all(fun(E) -> is_binary(E) end, Value);
check_type(Value, {list, number}) when is_list(Value) ->
    lists:all(fun(E) -> is_number(E) end, Value);
check_type(Value, {list, integer}) when is_list(Value) ->
    lists:all(fun(E) -> is_integer(E) end, Value);
check_type(Value, map) when is_map(Value) ->
    true;
check_type(Value, {map, {binary, binary}}) when is_map(Value) ->
    lists:all(fun({K, V}) -> is_binary(K) andalso is_binary(V) end, maps:to_list(Value));
check_type(Value, {map, {binary, any}}) when is_map(Value) ->
    lists:all(fun({K, _}) -> is_binary(K) end, maps:to_list(Value));
check_type(Value, boolean) ->
    is_boolean(Value);
check_type(_, _) ->
    false.

-spec check_healthcheck(map()) -> [binary()].
check_healthcheck(Config) ->
    case maps:get(<<"healthcheck">>, Config, undefined) of
        undefined ->
            [];
        Healthcheck when is_map(Healthcheck) ->
            Fields = [
                {<<"test">>, {list, binary}},
                {<<"interval">>, duration},
                {<<"timeout">>, duration},
                {<<"retries">>, non_neg_integer}
            ],
            KeyErrors = [<<"optional parameter: <<\"healthcheck\">>, type must be: map of string:any">> ||
                {Key, _Value} <- maps:to_list(Healthcheck), not is_binary(Key)],
            KeyErrors ++ check_optional(Healthcheck, Fields);
        _ ->
            []
    end.

-spec build_container_deploy_params(map()) -> map().
build_container_deploy_params(Config) when is_map(Config) ->
    #{
        container_name => maps:get(<<"container_name">>, Config),
        create => build_docker_create_options(Config)
    }.

-spec build_docker_create_options(map()) -> map().
build_docker_create_options(Config) when is_map(Config) ->
    #{
        config => build_docker_container_config(Config),
        host_config => build_docker_host_config(Config),
        networking_config => build_docker_networking_config(Config)
    }.

-spec build_docker_container_config(map()) -> map().
build_docker_container_config(Config) when is_map(Config) ->
    #{
        image => maps:get(<<"image">>, Config),
        cmd => maps:get(<<"command">>, Config),
        entrypoint => maps:get(<<"entrypoint">>, Config, []),
        env => maps:get(<<"envs">>, Config, []),
        labels => maps:get(<<"labels">>, Config, #{}),
        volumes => build_container_volumes(maps:get(<<"volumes">>, Config, [])),
        user => maps:get(<<"user">>, Config, <<>>),
        working_dir => maps:get(<<"working_dir">>, Config, <<>>),
        hostname => maps:get(<<"hostname">>, Config, <<>>),
        exposed_ports => build_exposed_ports(Config),
        healthcheck => build_healthcheck(maps:get(<<"healthcheck">>, Config, undefined))
    }.

-spec build_docker_host_config(map()) -> map().
build_docker_host_config(Config) when is_map(Config) ->
    #{
        binds => build_host_binds(maps:get(<<"volumes">>, Config, [])),
        network_mode => maps:get(<<"network_mode">>, Config, <<>>),
        restart_policy => build_restart_policy(maps:get(<<"restart">>, Config)),
        privileged => maps:get(<<"privileged">>, Config, false),
        cap_add => maps:get(<<"cap_add">>, Config, []),
        cap_drop => maps:get(<<"cap_drop">>, Config, []),
        devices => build_device_mappings(maps:get(<<"devices">>, Config, [])),
        memory => default_uint64(parse_optional_size_bytes(maps:get(<<"mem_limit">>, Config, undefined), <<"mem_limit">>)),
        memory_reservation => default_uint64(parse_optional_size_bytes(maps:get(<<"mem_reservation">>, Config, undefined), <<"mem_reservation">>)),
        nano_cpus => default_uint64(parse_optional_nano_cpus(maps:get(<<"cpus">>, Config, undefined))),
        cpu_shares => default_uint64(maps:get(<<"cpu_shares">>, Config, undefined)),
        port_bindings => build_port_bindings(maps:get(<<"ports">>, Config, [])),
        ulimits => build_ulimits(maps:get(<<"ulimits">>, Config, #{})),
        tmpfs => maps:from_list(build_tmpfs_options(maps:get(<<"tmpfs">>, Config, []))),
        sysctls => maps:get(<<"sysctls">>, Config, #{}),
        extra_hosts => maps:get(<<"extra_hosts">>, Config, [])
    }.

-spec build_docker_networking_config(map()) -> map().
build_docker_networking_config(Config) when is_map(Config) ->
    Networks = maps:get(<<"networks">>, Config, []),
    #{endpoints => [#{name => Network} || Network <- Networks]}.

-spec build_restart_policy(binary()) -> map().
build_restart_policy(Restart0) when is_binary(Restart0) ->
    case binary:split(Restart0, <<":">>) of
        [Name, RetryCountBin] ->
            #{name => Name, maximum_retry_count => parse_uint32(RetryCountBin, <<"restart">>)};
        [Name] ->
            #{name => Name, maximum_retry_count => 0}
    end.

-spec build_healthcheck(undefined | map()) -> undefined | map().
build_healthcheck(undefined) ->
    undefined;
build_healthcheck(Healthcheck) when is_map(Healthcheck) ->
    #{
        test => maps:get(<<"test">>, Healthcheck, []),
        interval_ns => parse_duration_ns(maps:get(<<"interval">>, Healthcheck, <<"0s">>), <<"healthcheck.interval">>),
        timeout_ns => parse_duration_ns(maps:get(<<"timeout">>, Healthcheck, <<"0s">>), <<"healthcheck.timeout">>),
        retries => maps:get(<<"retries">>, Healthcheck, 0)
    }.

-spec default_uint64(undefined | non_neg_integer()) -> non_neg_integer().
default_uint64(undefined) ->
    0;
default_uint64(Value) when is_integer(Value), Value >= 0 ->
    Value.

-spec parse_optional_nano_cpus(undefined | number()) -> undefined | non_neg_integer().
parse_optional_nano_cpus(undefined) ->
    undefined;
parse_optional_nano_cpus(Cpus) when is_integer(Cpus), Cpus >= 0 ->
    Cpus * 1000000000;
parse_optional_nano_cpus(Cpus) when is_float(Cpus), Cpus >= 0 ->
    trunc(Cpus * 1000000000).

-spec build_container_volumes([binary()]) -> [binary()].
build_container_volumes(VolumeSpecs) when is_list(VolumeSpecs) ->
    [ContainerPath || VolumeSpec <- VolumeSpecs, {_HostPath, ContainerPath, _ReadOnly} <- [parse_volume_spec(VolumeSpec)]].

-spec build_host_binds([binary()]) -> [binary()].
build_host_binds(VolumeSpecs) when is_list(VolumeSpecs) ->
    [volume_bind(HostPath, ContainerPath, ReadOnly) ||
        VolumeSpec <- VolumeSpecs,
        {HostPath, ContainerPath, ReadOnly} <- [parse_volume_spec(VolumeSpec)]].

-spec parse_volume_spec(binary()) -> {binary(), binary(), boolean()}.
parse_volume_spec(VolumeSpec) when is_binary(VolumeSpec) ->
    case binary:split(VolumeSpec, <<":">>, [global]) of
        [HostPath, ContainerPath] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
            {HostPath, ContainerPath, false};
        [HostPath, ContainerPath | Modes] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
            {HostPath, ContainerPath, lists:member(<<"ro">>, Modes)};
        _ ->
            throw({error, <<"invalid volume binding">>})
    end.

-spec volume_bind(binary(), binary(), boolean()) -> binary().
volume_bind(HostPath, ContainerPath, true) when is_binary(HostPath), is_binary(ContainerPath) ->
    <<HostPath/binary, ":", ContainerPath/binary, ":ro">>;
volume_bind(HostPath, ContainerPath, false) when is_binary(HostPath), is_binary(ContainerPath) ->
    <<HostPath/binary, ":", ContainerPath/binary>>.

-spec build_exposed_ports(map()) -> [map()].
build_exposed_ports(Config) when is_map(Config) ->
    ExposePorts = [build_exposed_port(ExposeSpec) || ExposeSpec <- maps:get(<<"expose">>, Config, [])],
    BoundPorts = [
        #{container_port => ContainerPort, protocol => Protocol} ||
        #{container_port := ContainerPort, protocol := Protocol} <- build_port_bindings(maps:get(<<"ports">>, Config, []))
    ],
    unique_ports(ExposePorts ++ BoundPorts).

-spec build_exposed_port(binary()) -> map().
build_exposed_port(ExposeSpec) when is_binary(ExposeSpec) ->
    case binary:split(ExposeSpec, <<"/">>) of
        [PortBin] ->
            #{container_port => parse_uint32(PortBin, <<"expose">>), protocol => <<"tcp">>};
        [PortBin, Protocol] ->
            #{container_port => parse_uint32(PortBin, <<"expose">>), protocol => Protocol}
    end.

-spec build_port_bindings([binary()]) -> [map()].
build_port_bindings(PortSpecs) when is_list(PortSpecs) ->
    [build_port_binding(PortSpec) || PortSpec <- PortSpecs].

-spec build_port_binding(binary()) -> map().
build_port_binding(PortSpec) when is_binary(PortSpec) ->
    case binary:split(PortSpec, <<":">>, [global]) of
        [HostPortBin, ContainerPortSpec] when HostPortBin =/= <<>>, ContainerPortSpec =/= <<>> ->
            {ContainerPort, Protocol} = parse_container_port_spec(ContainerPortSpec, <<"ports">>),
            #{
                host_ip => <<>>,
                host_port => parse_tcp_port(HostPortBin, <<"ports.host_port">>),
                container_port => ContainerPort,
                protocol => Protocol
            };
        _ ->
            throw({error, <<"invalid port binding">>})
    end.

-spec parse_container_port_spec(binary(), binary()) -> {non_neg_integer(), binary()}.
parse_container_port_spec(PortSpec, Field) when is_binary(PortSpec), is_binary(Field) ->
    case binary:split(PortSpec, <<"/">>) of
        [PortBin] ->
            {parse_tcp_port(PortBin, Field), <<"tcp">>};
        [PortBin, Protocol] when Protocol =/= <<>> ->
            {parse_tcp_port(PortBin, Field), Protocol};
        _ ->
            throw({error, <<"invalid port binding">>})
    end.

-spec unique_ports([map()]) -> [map()].
unique_ports(Ports) when is_list(Ports) ->
    {_Seen, Result} = lists:foldl(
        fun(Port = #{container_port := ContainerPort, protocol := Protocol}, {Seen, Acc}) ->
            Key = {ContainerPort, Protocol},
            case maps:is_key(Key, Seen) of
                true ->
                    {Seen, Acc};
                false ->
                    {Seen#{Key => true}, [Port | Acc]}
            end
        end,
        {#{}, []},
        Ports),
    lists:reverse(Result).

-spec build_device_mappings([binary()]) -> [map()].
build_device_mappings(DeviceSpecs) when is_list(DeviceSpecs) ->
    [build_device_mapping(DeviceSpec) || DeviceSpec <- DeviceSpecs].

-spec build_device_mapping(binary()) -> map().
build_device_mapping(DeviceSpec) when is_binary(DeviceSpec) ->
    case binary:split(DeviceSpec, <<":">>, [global]) of
        [HostPath, ContainerPath] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
            #{path_on_host => HostPath, path_in_container => ContainerPath, cgroup_permissions => <<"rwm">>};
        [HostPath, ContainerPath, Permissions] when HostPath =/= <<>>, ContainerPath =/= <<>>, Permissions =/= <<>> ->
            #{path_on_host => HostPath, path_in_container => ContainerPath, cgroup_permissions => Permissions};
        _ ->
            throw({error, <<"invalid device mapping">>})
    end.

-spec build_ulimits(map()) -> [map()].
build_ulimits(Ulimits) when is_map(Ulimits) ->
    [build_ulimit(Name, Value) || {Name, Value} <- maps:to_list(Ulimits)].

-spec build_ulimit(binary(), binary()) -> map().
build_ulimit(Name, Value) when is_binary(Name), is_binary(Value) ->
    case binary:split(Value, <<":">>) of
        [SoftBin, HardBin] ->
            #{name => Name, soft => parse_uint64(SoftBin, <<"ulimits.soft">>), hard => parse_uint64(HardBin, <<"ulimits.hard">>)};
        [LimitBin] ->
            Limit = parse_uint64(LimitBin, <<"ulimits.limit">>),
            #{name => Name, soft => Limit, hard => Limit}
    end.

-spec build_tmpfs_options([binary()]) -> [{binary(), binary()}].
build_tmpfs_options(TmpfsSpecs) when is_list(TmpfsSpecs) ->
    [build_tmpfs_option(TmpfsSpec) || TmpfsSpec <- TmpfsSpecs].

-spec build_tmpfs_option(binary()) -> {binary(), binary()}.
build_tmpfs_option(TmpfsSpec) when is_binary(TmpfsSpec) ->
    case binary:split(TmpfsSpec, <<":">>) of
        [Path] when Path =/= <<>> ->
            {Path, <<>>};
        [Path, Options] when Path =/= <<>> ->
            {Path, Options};
        _ ->
            throw({error, <<"invalid tmpfs mount">>})
    end.

-spec parse_optional_size_bytes(undefined | binary(), binary()) -> undefined | non_neg_integer().
parse_optional_size_bytes(undefined, _Field) ->
    undefined;
parse_optional_size_bytes(Value, Field) when is_binary(Value) ->
    parse_size_bytes(Value, Field).

-spec parse_duration_ns(binary() | integer(), binary()) -> non_neg_integer().
parse_duration_ns(Value, _Field) when is_integer(Value), Value >= 0 ->
    Value;
parse_duration_ns(Value, Field) when is_binary(Value) ->
    parse_scaled_uint64(Value, Field, #{
        <<"ns">> => 1,
        <<"us">> => 1000,
        <<"ms">> => 1000000,
        <<"s">> => 1000000000,
        <<"m">> => 60000000000,
        <<"h">> => 3600000000000,
        <<>> => 1000000000
    }).

-spec parse_size_bytes(binary(), binary()) -> non_neg_integer().
parse_size_bytes(Value, Field) when is_binary(Value) ->
    parse_scaled_uint64(Value, Field, #{
        <<"b">> => 1,
        <<"k">> => 1024,
        <<"kb">> => 1024,
        <<"ki">> => 1024,
        <<"kib">> => 1024,
        <<"m">> => 1048576,
        <<"mb">> => 1048576,
        <<"mi">> => 1048576,
        <<"mib">> => 1048576,
        <<"g">> => 1073741824,
        <<"gb">> => 1073741824,
        <<"gi">> => 1073741824,
        <<"gib">> => 1073741824,
        <<"t">> => 1099511627776,
        <<"tb">> => 1099511627776,
        <<"ti">> => 1099511627776,
        <<"tib">> => 1099511627776,
        <<>> => 1
    }).

-spec parse_scaled_uint64(binary(), binary(), map()) -> non_neg_integer().
parse_scaled_uint64(Value0, Field, Multipliers) when is_binary(Value0), is_binary(Field), is_map(Multipliers) ->
    Value = trim_binary(Value0),
    LowerValue = lower_binary(Value),
    {NumberBin, Unit} = split_numeric_suffix(LowerValue),
    case maps:get(Unit, Multipliers, undefined) of
        undefined ->
            throw({error, <<"invalid value for ", Field/binary, ": ", Value0/binary>>});
        Multiplier ->
            trunc(parse_decimal(NumberBin, Field) * Multiplier)
    end.

-spec parse_uint32(binary(), binary()) -> non_neg_integer().
parse_uint32(Value, Field) when is_binary(Value), is_binary(Field) ->
    Parsed = parse_uint64(Value, Field),
    case Parsed =< 16#FFFFFFFF of
        true ->
            Parsed;
        false ->
            throw({error, <<"value overflow for ", Field/binary>>})
    end.

-spec parse_tcp_port(binary(), binary()) -> non_neg_integer().
parse_tcp_port(Value, Field) when is_binary(Value), is_binary(Field) ->
    Parsed = parse_uint32(Value, Field),
    case Parsed =< 65535 of
        true ->
            Parsed;
        false ->
            throw({error, <<"port out of range for ", Field/binary>>})
    end.

-spec parse_uint64(binary(), binary()) -> non_neg_integer().
parse_uint64(Value0, Field) when is_binary(Value0), is_binary(Field) ->
    Value = trim_binary(Value0),
    case catch binary_to_integer(Value) of
        Parsed when is_integer(Parsed), Parsed >= 0 ->
            Parsed;
        _ ->
            throw({error, <<"invalid unsigned integer for ", Field/binary, ": ", Value0/binary>>})
    end.

-spec parse_decimal(binary(), binary()) -> float().
parse_decimal(Value, Field) when is_binary(Value), is_binary(Field) ->
    case catch binary_to_integer(Value) of
        ParsedInt when is_integer(ParsedInt), ParsedInt >= 0 ->
            float(ParsedInt);
        _ ->
            case catch binary_to_float(Value) of
                ParsedFloat when is_float(ParsedFloat), ParsedFloat >= 0 ->
                    ParsedFloat;
                _ ->
                    throw({error, <<"invalid number for ", Field/binary, ": ", Value/binary>>})
            end
    end.

-spec split_numeric_suffix(binary()) -> {binary(), binary()}.
split_numeric_suffix(Value) when is_binary(Value) ->
    split_numeric_suffix(Value, <<>>).

-spec split_numeric_suffix(binary(), binary()) -> {binary(), binary()}.
split_numeric_suffix(<<Char, Rest/binary>>, Acc)
  when (Char >= $0 andalso Char =< $9) orelse Char =:= $. ->
    split_numeric_suffix(Rest, <<Acc/binary, Char>>);
split_numeric_suffix(Rest, <<>>) ->
    throw({error, <<"invalid numeric value: ", Rest/binary>>});
split_numeric_suffix(Rest, Acc) ->
    {Acc, Rest}.

-spec trim_binary(binary()) -> binary().
trim_binary(Value) when is_binary(Value) ->
    Trimmed = string:trim(binary_to_list(Value)),
    list_to_binary(Trimmed).

-spec lower_binary(binary()) -> binary().
lower_binary(Value) when is_binary(Value) ->
    list_to_binary(string:lowercase(binary_to_list(Value))).
