%% -------------------------------------------------------------------
%%
%% cuttlefish_datatypes: handles datatypes in cuttlefish schemas
%%
%% Copyright (c) 2013 Basho Technologies, Inc.  All Rights Reserved.
%%
%% This file is provided to you under the Apache License,
%% Version 2.0 (the "License"); you may not use this file
%% except in compliance with the License.  You may obtain
%% a copy of the License at
%%
%%   http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing,
%% software distributed under the License is distributed on an
%% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
%% KIND, either express or implied.  See the License for the
%% specific language governing permissions and limitations
%% under the License.
%%
%% -------------------------------------------------------------------
-module(cuttlefish_datatypes).

-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.

-type datatype() :: integer |
                    string |
                    atom |
                    boolean |
                    file |
                    directory |
                    flag |
                    {flag, atom(), atom()} |
                    {flag, {atom(), term()}, {atom(), term()}} |
                    {enum, [atom()]} |
                    ip |
                    fqdn |
                    domain_socket |
                    {duration, cuttlefish_duration:time_unit() } |
                    {duration, cuttlefish_duration:time_unit(), [numeric_constraint()] | numeric_shortcut()} |
                    bytesize |
                    {bytesize, [numeric_constraint()] | numeric_shortcut()} |
                    {percent, integer} |
                    {percent, float} |
                    percent |
                    port |
                    byte |
                    {integer, [numeric_constraint()] | numeric_shortcut()} |
                    {float,   [numeric_constraint()] | numeric_shortcut()} |
                    float |
                    tagged_string |
                    regex |
                    uri |
                    {uri, [atom()]} |
                    {list, datatype()}.

-type numeric_constraint() :: {min, number()}
                            | {max, number()}
                            | {gt,  number()}
                            | {lt,  number()}
                            | numeric_shortcut()
                            | allow_infinity
                            | {validator, validator_fun()}
                            | {validator, string()}.

-type numeric_shortcut() :: non_negative | positive.
-type validator_fun() :: fun((term()) -> boolean()).
-type extended() :: { integer, integer() } |
                    { string, string() } |
                    { binary, binary() } |
                    { file, file:filename() } |
                    { directory, file:filename() } |
                    { atom, atom() } |
                    { ip, { string(), integer() } } |
                    { domain_socket, {string(), integer()} } |
                    { {duration, cuttlefish_duration:time_unit() }, string() } |
                    { bytesize, string() } |
                    { {percent, integer}, integer() } |
                    { {percent, float}, float() } |
                    { float, float() } |
                    { tagged_string, string() } |
                    { tagged_binary, string() }.
-type datatype_list() :: [ datatype() | extended() ].

-export_type([datatype/0, extended/0, datatype_list/0]).

-export([
         is_supported/1,
         is_extended/1,
         is_valid_list/1,
         from_string/2,
         to_string/2,
         extended_from/1,
         constraint_validator_refs/1,
         resolve_constraint_validators/2
]).

-spec is_supported(any()) -> boolean().
is_supported(integer) -> true;
is_supported(string) -> true;
is_supported(binary) -> true;
is_supported(file) -> true;
is_supported(directory) -> true;
is_supported(flag) -> true;
is_supported({flag, On, Off}) when is_atom(On), is_atom(Off) -> true;
is_supported({flag, {On, _}, {Off, _}}) when is_atom(On), is_atom(Off) -> true;
is_supported(atom) -> true;
is_supported(boolean) -> true;
is_supported({enum, E}) when is_list(E) -> true;
is_supported(ip) -> true;
is_supported(fqdn) -> true;
is_supported(domain_socket) -> true;
is_supported({duration, f}) -> true;
is_supported({duration, w}) -> true;
is_supported({duration, d}) -> true;
is_supported({duration, h}) -> true;
is_supported({duration, m}) -> true;
is_supported({duration, s}) -> true;
is_supported({duration, ms}) -> true;
is_supported(bytesize) -> true;
is_supported({bytesize, Constraints}) ->
    valid_constraints(Constraints, integer);
is_supported({duration, Unit, Constraints})
  when Unit =:= f; Unit =:= w; Unit =:= d; Unit =:= h;
       Unit =:= m; Unit =:= s; Unit =:= ms ->
    valid_constraints(Constraints, integer);
is_supported({percent, integer}) -> true;
is_supported({percent, float}) -> true;
is_supported(percent) -> true;
is_supported(port) -> true;
is_supported(byte) -> true;
is_supported({integer, Constraints}) ->
    valid_constraints(Constraints, integer);
is_supported({float, Constraints}) ->
    valid_constraints(Constraints, float);
is_supported(float) -> true;
is_supported(tagged_string) -> true;
is_supported(tagged_binary) -> true;
is_supported(regex) -> true;
is_supported(uri) -> true;
is_supported({uri, Schemes}) when is_list(Schemes), Schemes =/= [] ->
    lists:all(fun is_atom/1, Schemes);
is_supported({list, {list, _}}) ->
    % lists of lists are not supported
    false;
is_supported({list, ListDatatype}) ->
    is_supported(ListDatatype);
is_supported(_) -> false.

-spec is_extended(any()) -> boolean().
is_extended({integer, I}) when is_integer(I) -> true;
is_extended({string, S}) when is_list(S) -> true;
is_extended({binary, B}) when is_list(B) -> true;
is_extended({tagged_string, S}) when is_list(S) -> true;
is_extended({tagged_binary, B}) when is_list(B) -> true;
is_extended({atom, A}) when is_atom(A) -> true;
is_extended({file, F}) when is_list(F) -> true;
is_extended({directory, D}) when is_list(D) -> true;
is_extended({ip, {IP, Port}}) when is_list(IP) andalso is_integer(Port) -> true;
is_extended({ip, StringIP}) when is_list(StringIP) -> true;
is_extended({fqdn, {FQDN, Port}}) when is_list(FQDN) andalso is_integer(Port) -> true;
is_extended({fqdn, StringFQDN}) when is_list(StringFQDN) -> true;
is_extended({domain_socket, {local, UDS, Port}}) when is_list(UDS) andalso is_integer(Port) -> true;
is_extended({domain_socket, StringUDS}) when is_list(StringUDS) -> true;
is_extended({{duration, f}, D}) when is_list(D) -> true;
is_extended({{duration, w}, D}) when is_list(D) -> true;
is_extended({{duration, d}, D}) when is_list(D) -> true;
is_extended({{duration, g}, D}) when is_list(D) -> true;
is_extended({{duration, m}, D}) when is_list(D) -> true;
is_extended({{duration, s}, D}) when is_list(D) -> true;
is_extended({{duration, ms}, D}) when is_list(D) -> true;
is_extended({bytesize, B}) when is_list(B) -> true;
is_extended({{percent, integer}, _Int}) -> true;
is_extended({{percent, float}, _Float}) -> true;
is_extended({float, F}) when is_float(F) -> true;
is_extended(_) -> false.

-spec extended_from(extended()) -> datatype().
extended_from({integer, _}) -> integer;
extended_from({string, _}) -> string;
extended_from({binary, _}) -> binary;
extended_from({tagged_string, _}) -> tagged_string;
extended_from({tagged_binary, _}) -> tagged_binary;
extended_from({atom, _}) -> atom;
extended_from({file, _}) -> file;
extended_from({directory, _}) -> directory;
extended_from({ip, _}) -> ip;
extended_from({fqdn, _}) -> fqdn;
extended_from({domain_socket, _}) -> domain_socket;
extended_from({{duration, Unit}, _}) -> {duration, Unit};
extended_from({bytesize, _}) -> bytesize;
extended_from({{percent, integer}, _}) -> {percent, integer};
extended_from({{percent, float}, _}) -> {percent, float};
extended_from({float, _}) -> float;
extended_from(Other) ->
    case is_supported(Other) of
        true ->
            Other;
        _ -> error
    end.

-spec is_valid_list(any()) -> boolean().
is_valid_list(NotList) when not is_list(NotList) ->
    false;
is_valid_list([]) -> false;
is_valid_list(List) ->
    lists:all(fun(X) ->
                  is_supported(X)
                      orelse
                  is_extended(X)
              end, List).

-spec to_string(term(), datatype()) -> string() | cuttlefish_error:error().
to_string(Atom, atom) when is_list(Atom) -> Atom;
to_string(Atom, atom) when is_atom(Atom) -> atom_to_list(Atom);

to_string(true, boolean) -> "true";
to_string(false, boolean) -> "false";
to_string("true", boolean) -> "true";
to_string("false", boolean) -> "false";

to_string(Integer, integer) when is_integer(Integer) -> integer_to_list(Integer);
to_string(Integer, integer) when is_list(Integer) -> Integer;

to_string(Value, port) -> to_string(Value, integer);
to_string(Value, byte) -> to_string(Value, integer);
to_string(Value, percent) -> to_string(Value, {percent, integer});
%% The `allow_infinity' shortcut leaves the atom `infinity' unchanged
%% on the conversion path; here the printable form is the literal
%% characters. These clauses must precede the generic constraint
%% clauses below — those would match first and recurse into the
%% integer/float clauses, which reject the atom.
to_string(infinity, {integer, _}) -> "infinity";
to_string(infinity, {float, _}) -> "infinity";
to_string(infinity, {bytesize, _}) -> "infinity";
to_string(infinity, {duration, _, _}) -> "infinity";
to_string(Value, {integer, Constraints}) when is_list(Constraints); is_atom(Constraints) ->
    to_string(Value, integer);
to_string(Value, {float, Constraints}) when is_list(Constraints); is_atom(Constraints) ->
    to_string(Value, float);
to_string(Value, {bytesize, Constraints}) when is_list(Constraints); is_atom(Constraints) ->
    to_string(Value, bytesize);
to_string(Value, {duration, Unit, Constraints}) when is_list(Constraints); is_atom(Constraints) ->
    to_string(Value, {duration, Unit});

to_string({IP, Port}, ip) when is_list(IP), is_integer(Port) -> IP ++ ":" ++ integer_to_list(Port);
to_string(IPString, ip) when is_list(IPString) -> IPString;

to_string({FQDN, Port}, fqdn) when is_list(FQDN), is_integer(Port) -> FQDN ++ ":" ++ integer_to_list(Port);
to_string(FQDNString, fqdn) when is_list(FQDNString) -> FQDNString;

to_string({local, UDS, Port}, domain_socket) when is_list(UDS), is_integer(Port) ->
    "local:" ++ UDS ++ ":" ++ integer_to_list(Port);
to_string(UDSString, domain_socket) when is_list(UDSString) -> UDSString;

to_string(Enum, {enum, _}) when is_list(Enum) -> Enum;
to_string(Enum, {enum, _}) when is_atom(Enum) -> atom_to_list(Enum);

to_string(Duration, {duration, _}) when is_list(Duration) -> Duration;
to_string(Duration, {duration, Unit}) when is_integer(Duration) -> cuttlefish_duration:to_string(Duration, Unit);

to_string(Bytesize, bytesize) when is_list(Bytesize) -> Bytesize;
to_string(Bytesize, bytesize) when is_integer(Bytesize) -> cuttlefish_bytesize:to_string(Bytesize);

to_string(String, string) when is_list(String) -> String;
to_string(Bin, binary) when is_list(Bin) -> Bin;
to_string(Bin, binary) when is_binary(Bin) -> binary_to_list(Bin);

to_string({Tag, String}, tagged_string) when is_list(Tag), is_list(String) -> Tag ++ ":" ++ String;
to_string({Tag, String}, tagged_binary) when is_list(Tag), is_list(String) -> Tag ++ ":" ++ String;
to_string({Tag, Bin}, tagged_binary) when is_list(Tag), is_binary(Bin) -> Tag ++ ":" ++ binary_to_list(Bin);

to_string(Regex, regex) when is_list(Regex) -> Regex;

to_string(URI, uri) when is_list(URI) -> URI;
to_string(URI, {uri, _}) when is_list(URI) -> URI;

to_string(File, file) when is_list(File) -> File;

to_string(Directory, directory) when is_list(Directory) -> Directory;

to_string(Flag, flag) when is_atom(Flag) -> cuttlefish_flag:to_string(Flag, flag);
to_string(Flag, flag) when is_list(Flag) -> cuttlefish_flag:to_string(Flag, flag);
to_string(Flag, {flag, _, _}=Type) when is_atom(Flag) -> cuttlefish_flag:to_string(Flag, Type);
to_string(Flag, {flag, _, _}=Type) when is_list(Flag) -> cuttlefish_flag:to_string(Flag, Type);

to_string(Percent, {percent, integer}) when is_integer(Percent) ->
    integer_to_list(Percent) ++ "%";
to_string(Percent, {percent, integer}) when is_list(Percent) ->
    Percent;
to_string(Percent, {percent, float}) when is_float(Percent) ->
    P = list_to_float(float_to_list(Percent * 100, [{decimals, 6}, compact])),
    integer_to_list(cuttlefish_util:ceiling(P)) ++ "%";
to_string(Percent, {percent, float}) when is_list(Percent) ->
    Percent;

to_string(Float, float) when is_float(Float) ->
    float_to_list(Float, [{decimals, 6}, compact]);
to_string(Float, float) when is_list(Float) -> Float;

%% The Pokemon Clause: Gotta Catch 'em all!
to_string(Value, MaybeExtendedDatatype) ->
    case is_extended(MaybeExtendedDatatype) of
        true ->
            to_string(Value, extended_from(MaybeExtendedDatatype));
        _ ->
            {error, {type, {Value, MaybeExtendedDatatype}}}
    end.

-define(TAGGED_VALUE_PREFIX_REGEX, <<"^[a-zA-Z0-9_]+\:">>).

-define(DEFAULT_URI_SCHEMES, [http, https]).

%% Match-step backtracking budget used when probing user patterns.
-define(MATCH_STEP_BUDGET, 1000).

%% Inputs used to probe user patterns for excessive backtracking.
-define(REGEX_PROBES, [
    "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!",
    "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "abababababababababababababababababababab!",
    "1111111111111111111111111111111111111111!",
    "abcdefghijklmnopqrstuvwxyz0123456789ABCD!",
    "aaaa.aaaa.aaaa.aaaa.aaaaaaaaaaaaaaaaaaaa!"
]).

-spec from_string(term(), datatype()) -> term() | cuttlefish_error:error().
from_string(Atom, atom) when is_atom(Atom) -> Atom;
from_string(String, atom) when is_list(String) -> list_to_atom(String);

from_string(true, boolean) -> true;
from_string(false, boolean) -> false;
from_string("true", boolean) -> true;
from_string("false", boolean) -> false;
from_string(Value, boolean) when is_list(Value); is_atom(Value) ->
    {error, {conversion, {Value, boolean}}};

from_string(String, binary) when is_list(String) -> list_to_binary(String);

from_string(Value, {enum, Enum}) ->
    cuttlefish_enum:parse(Value, {enum, Enum});

from_string(Integer, integer) when is_integer(Integer) -> Integer;
from_string(String, integer) when is_list(String) ->
    try list_to_integer(String) of
        X -> X
    catch
        _:_ -> {error, {conversion, {String, integer}}}
    end;

from_string(Value, port) ->
    from_string(Value, {integer, [{min, 0}, {max, 65535}]});

from_string(Value, byte) ->
    from_string(Value, {integer, [{min, 0}, {max, 255}]});

from_string(Value, percent) ->
    from_string(Value, {percent, integer});

from_string(Value, {integer, Constraints}) ->
    case maybe_infinity(Value, Constraints) of
        {ok, Inf}      -> Inf;
        not_applicable ->
            apply_numeric_constraints(from_string(Value, integer),
                                      Constraints, integer)
    end;

from_string(Value, {float, Constraints}) ->
    case maybe_infinity(Value, Constraints) of
        {ok, Inf}      -> Inf;
        not_applicable ->
            apply_numeric_constraints(from_string(Value, float),
                                      Constraints, float)
    end;

from_string({IP, Port}, ip) when is_list(IP), is_integer(Port) -> {IP, Port};
from_string(String, ip) when is_list(String) ->
    from_string_to_ip(String, lists:split(string:rchr(String, $:), String));

from_string({FQDN, Port}, fqdn) when is_list(FQDN), is_integer(Port) -> {FQDN, Port};
from_string(String, fqdn) when is_list(String) ->
    from_string_to_fqdn(String, lists:split(string:rchr(String, $:), String));

from_string(String, tagged_string) when is_list(String) ->
    Bin = list_to_binary(String),
    {ok, AlphanumericPrefixRE} = re:compile(?TAGGED_VALUE_PREFIX_REGEX),
    case re:run(Bin, AlphanumericPrefixRE) of
        {match, _} ->
            %% note: unlike lists:split/2, this splits at the first colon occurrence,
            %% which is what we want here
            case binary:split(Bin, <<":">>) of
                [<<>>, _Val] ->
                    {error, {conversion, {String, "tagged string"}}};
                [_Prefix, <<>>] ->
                    {error, {conversion, {String, "tagged string"}}};
                [Prefix, Val] ->
                    from_string_to_tagged_string(String, {Prefix, Val});
                _ ->
                    {error, {conversion, {String, "tagged string"}}}
            end;
        _ ->
            {error, {conversion, {String, "tagged string"}}}
    end;

from_string(String, tagged_binary) when is_list(String) ->
    Bin = list_to_binary(String),
    {ok, AlphanumericPrefixRE} = re:compile(?TAGGED_VALUE_PREFIX_REGEX),
    case re:run(Bin, AlphanumericPrefixRE) of
        {match, _} ->
            %% note: unlike lists:split/2, this splits at the first colon occurrence,
            %% which is what we want here
            case binary:split(Bin, <<":">>) of
                [<<>>, _Val] ->
                    {error, {conversion, {String, "tagged binary"}}};
                [_Prefix, <<>>] ->
                    {error, {conversion, {String, "tagged binary"}}};
                [Prefix, Val] ->
                    from_string_to_tagged_binary(String, {Prefix, Val});
                _ ->
                    {error, {conversion, {String, "tagged binary"}}}
            end;
        _ ->
            {error, {conversion, {String, "tagged binary"}}}
    end;

from_string({local, UDS, Port}, domain_socket) when is_list(UDS), is_integer(Port) -> {local, UDS, Port};
from_string(String, domain_socket) when is_list(String) ->
    from_string_to_uds(String, lists:split(string:rchr(String, $:), String));

from_string(Duration, {duration, _}) when is_integer(Duration) -> Duration;
from_string(Duration, {duration, Unit}) when is_list(Duration) -> cuttlefish_duration:parse(Duration, Unit);

from_string(Value, {duration, Unit, Constraints}) ->
    case maybe_infinity(Value, Constraints) of
        {ok, Inf}      -> Inf;
        not_applicable ->
            apply_numeric_constraints(from_string(Value, {duration, Unit}),
                                      Constraints, integer)
    end;

from_string(Bytesize, bytesize) when is_integer(Bytesize) -> Bytesize;
from_string(Bytesize, bytesize) when is_list(Bytesize) -> cuttlefish_bytesize:parse(Bytesize);

from_string(Value, {bytesize, Constraints}) ->
    case maybe_infinity(Value, Constraints) of
        {ok, Inf}      -> Inf;
        not_applicable ->
            apply_numeric_constraints(from_string(Value, bytesize),
                                      Constraints, integer)
    end;

from_string(String, string) when is_list(String) -> String;

from_string(String, regex) when is_list(String) ->
    validate_regex(String);

from_string(String, uri) when is_list(String) ->
    validate_uri(String, ?DEFAULT_URI_SCHEMES);
from_string(String, {uri, Schemes}) when is_list(String), is_list(Schemes) ->
    validate_uri(String, Schemes);

from_string(File, file) when is_list(File) -> File;

from_string(Directory, directory) when is_list(Directory) -> Directory;

from_string(Flag, flag) when is_list(Flag) -> cuttlefish_flag:parse(Flag);
from_string(Flag, flag) when is_atom(Flag) -> cuttlefish_flag:parse(Flag);

from_string(Flag, {flag, _, _}=Type) when is_list(Flag) -> cuttlefish_flag:parse(Flag, Type);
from_string(Flag, {flag, _, _}=Type) when is_atom(Flag) -> cuttlefish_flag:parse(Flag, Type);

from_string(Percent, {percent, integer}) when is_integer(Percent), Percent >= 0, Percent =< 100 -> Percent;
from_string(Percent, {percent, integer}) when is_integer(Percent) ->
    {error, {range, {{Percent, {percent, integer}}, "0 - 100%"}}};
%% This clause ends with a percent sign!
from_string(Percent, {percent, integer}) when is_list(Percent) ->
    from_string(
      list_to_integer(string:sub_string(Percent, 1, length(Percent) - 1)),
      {percent, integer});

from_string(Percent, {percent, float}) when is_float(Percent), Percent >= 0, Percent =< 1 -> Percent;
from_string(Percent, {percent, float}) when is_float(Percent) ->
    {error, {range, {{Percent, {percent, float}}, "0 - 100%"}}};
%% This clause ends with a percent sign!
from_string(Percent, {percent, float}) when is_list(Percent) ->
    from_string(
      list_to_integer(string:sub_string(Percent, 1, length(Percent) - 1)) / 100.0,
      {percent, float});

from_string(Float, float) when is_float(Float) -> Float;
from_string(String, float) when is_list(String) ->
    try list_to_float(String) of
        X -> X
    catch
        _:_ -> {error, {conversion, {String, float}}}
    end;

from_string(List, {list, DataType}) when is_list(List) ->
    lists:map(fun(El) ->
                  from_string(string:trim(El), DataType)
              end,
              string:split(List, ",", all));

from_string(Thing, InvalidDatatype) ->
   {error, {type, {Thing, InvalidDatatype}}}.


%%% Utility functions for IP conversion

port_to_integer(Str) ->
    try
        list_to_integer(Str)
    of
        X when X >= 0 ->
            X;
        %% Negative ports are nonsensical
        _X ->
            undefined
    catch
        _:_ ->
            undefined
    end.

ip_conversions(String, _IPStr, {error, einval}, _Port) ->
    {error, {conversion, {String, 'IP'}}};
ip_conversions(String, _IPStr, _IP, undefined) ->
    {error, {conversion, {String, 'IP'}}};
ip_conversions(_String, IPStr, {ok, _}, Port) ->
    {IPStr, Port}.

fqdn_conversions(String, _FQDNStr, nomatch, _Port) ->
    {error, {conversion, {String, 'FQDN'}}};
fqdn_conversions(String, _FQDNStr, _, undefined) ->
    {error, {conversion, {String, 'FQDN'}}};
fqdn_conversions(_String, FQDNStr, {match, _}, Port) ->
    {FQDNStr, Port}.

uds_conversions(String, _UDSStr, nomatch) ->
    {error, {conversion, {String, 'UDS'}}};
uds_conversions(_String, _UDSStr, {match, Path}) ->
    %% port is always 0 for unix domain sockets
    {local, Path, 0}.

validate_uds(Str) ->
    case string:tokens(Str, ":") of
        [Pfx, Path] when Pfx =:= "local" orelse Pfx =:= "unix"  ->
            {match, Path};
        _ ->
            nomatch
    end.

validate_fqdn(Str) ->
    %% inspired by https://regexr.com/3g5j0, amended to disallow [:space:]
    re:run(Str, "^(?!:\/\/)(?=[^[:space:]]{1,255}$)((.{1,63}\.){1,127}(?![0-9]*$)[a-z0-9-]+\.?)$", [unicode]).

droplast(List) ->
    lists:sublist(List, length(List)-1).

from_string_to_ip(String, {[], String}) ->
    {error, {conversion, {String, 'IP'}}}; %% No port
from_string_to_ip(String, {IpPlusColon, PortString}) ->
    %% Still need to drop last character from IP, the trailing
    %% colon. Perfect use case for lists:droplast/1 but it's a recent
    %% addition
    IP = droplast(IpPlusColon),
    ip_conversions(String, IP, inet:parse_address(IP), port_to_integer(PortString)).

from_string_to_fqdn(String, {[], String}) ->
    {error, {conversion, {String, 'FQDN'}}};
from_string_to_fqdn(String, {FQDNPlusColon, PortString}) ->
    FQDN = droplast(FQDNPlusColon),
    fqdn_conversions(String, FQDN, validate_fqdn(FQDN), port_to_integer(PortString)).

from_string_to_tagged_string(_String, {Tag, TaggedValue}) ->
    {binary_to_atom(Tag), binary_to_list(TaggedValue)}.

from_string_to_tagged_binary(_String, {Tag, TaggedValue}) ->
    {binary_to_atom(Tag), TaggedValue}.

from_string_to_uds(String, {[], String}) ->
    {error, {conversion, {String, 'UDS'}}};
from_string_to_uds(String, {UDSPlusColon, PortString}) ->
    %% Drop the trailing colon
    UDS = droplast(UDSPlusColon),

    %% In most API functions where you can use this address family
    %% the port number must be 0.
    %% See: https://www.erlang.org/doc/man/inet.html#type-local_address
    case port_to_integer(PortString) of
        0 ->
            uds_conversions(String, UDS, validate_uds(UDS));
        _OtherPort ->
            {error, {conversion, {String, 'UDS'}}}
    end.

validate_regex("") ->
    {error, {regex_empty, ""}};
validate_regex(String) when is_list(String) ->
    case re:compile(String) of
        {error, {Reason, Pos}} ->
            {error, {regex_invalid_syntax, {String, Reason, Pos}}};
        {ok, MP} ->
            %% `report_errors' is required: without it, PCRE silently turns
            %% a match_limit exhaustion into `nomatch'.
            Opts = [{match_limit, ?MATCH_STEP_BUDGET},
                    {match_limit_recursion, ?MATCH_STEP_BUDGET},
                    report_errors],
            case probe_backtracking(String, MP, Opts) of
                ok    -> String;
                over_budget -> {error, {regex_excessive_backtracking, String}}
            end
    end.

probe_backtracking(String, MP, Opts) ->
    case probe_all(MP, ?REGEX_PROBES, Opts) of
        over_budget -> over_budget;
        ok    -> probe_anchored(String, Opts)
    end.

probe_anchored(String, Opts) ->
    case re:compile("^(?:" ++ String ++ ")$") of
        %% Rare patterns (e.g. PCRE verbs that don't compose with grouping)
        %% can fail to compile when wrapped. Fall back to the plain probe.
        {error, _} ->
            ok;
        {ok, Anchored} ->
            probe_all(Anchored, ?REGEX_PROBES, Opts)
    end.

probe_all(_MP, [], _Opts) -> ok;
probe_all(MP, [Probe | Rest], Opts) ->
    case re:run(Probe, MP, Opts) of
        {error, match_limit}           -> over_budget;
        {error, match_limit_recursion} -> over_budget;
        _                              -> probe_all(MP, Rest, Opts)
    end.

validate_uri(String, Schemes) ->
    case validate_uri_schemes(Schemes) of
        ok    -> do_validate_uri(string:trim(String), Schemes);
        Error -> Error
    end.

do_validate_uri("", _Schemes) ->
    {error, {uri_empty, ""}};
do_validate_uri(String, Schemes) ->
    case uri_string:parse(String) of
        {error, _Reason, _Term} ->
            {error, {uri_malformed, String}};
        Parsed when is_map(Parsed) ->
            check_scheme_and_host(String, Parsed, Schemes)
    end.

check_scheme_and_host(String, Parsed, Schemes) ->
    case maps:find(scheme, Parsed) of
        error ->
            {error, {uri_no_scheme, String}};
        {ok, SchemeStr} ->
            Lower = string:lowercase(SchemeStr),
            Allowed = [atom_to_list(S) || S <- Schemes],
            case lists:member(Lower, Allowed) of
                false ->
                    {error, {uri_bad_scheme, {String, Lower, Schemes}}};
                true ->
                    check_host(String, Parsed)
            end
    end.

check_host(String, Parsed) ->
    case maps:get(host, Parsed, "") of
        "" -> {error, {uri_no_host, String}};
        _  -> String
    end.

validate_uri_schemes([]) ->
    {error, {uri_schemes_empty, []}};
validate_uri_schemes(Schemes) when is_list(Schemes) ->
    case lists:all(fun is_atom/1, Schemes) of
        true -> ok;
        _    -> {error, {uri_schemes_invalid, Schemes}}
    end.

valid_constraints(non_negative, _BaseType) -> true;
valid_constraints(positive,     _BaseType) -> true;
valid_constraints(allow_infinity, _BaseType) -> true;
valid_constraints(Constraints, BaseType) when is_list(Constraints) ->
    lists:all(fun(C) -> valid_constraint(C, BaseType) end, Constraints);
valid_constraints(_, _) -> false.

valid_constraint(non_negative, _) -> true;
valid_constraint(positive,     _) -> true;
valid_constraint(allow_infinity, _) -> true;
valid_constraint({min, N}, T) -> is_bound(N, T);
valid_constraint({max, N}, T) -> is_bound(N, T);
valid_constraint({gt,  N}, T) -> is_bound(N, T);
valid_constraint({lt,  N}, T) -> is_bound(N, T);
valid_constraint({validator, Fun}, _) when is_function(Fun, 1) -> true;
valid_constraint({validator, Name}, _) when is_list(Name) -> true;
valid_constraint(_, _) -> false.

is_bound(N, integer) -> is_integer(N);
is_bound(N, float)   -> is_number(N).

%% `allow_infinity' in the constraint list lets the atom `infinity'
%% bypass numeric parsing and bound checks. Conf-file values arrive
%% as the matching string; both forms are accepted. When the bypass
%% fires the remaining constraints are skipped: the atom is meant to
%% be a valid value regardless of bounds.
maybe_infinity(Value, Constraints) ->
    case allows_infinity(Constraints) andalso is_infinity_value(Value) of
        true  -> {ok, infinity};
        false -> not_applicable
    end.

allows_infinity(allow_infinity) -> true;
allows_infinity(Constraints) when is_list(Constraints) ->
    lists:member(allow_infinity, Constraints);
allows_infinity(_) -> false.

is_infinity_value(infinity) -> true;
is_infinity_value("infinity") -> true;
is_infinity_value(<<"infinity">>) -> true;
is_infinity_value(_) -> false.

apply_numeric_constraints({error, _} = E, _Constraints, _BaseType) ->
    E;
apply_numeric_constraints(Value, Constraints, BaseType) ->
    check_constraints(Value, expand_constraints(Constraints, BaseType)).

expand_constraints(non_negative, integer) -> [{min, 0}];
expand_constraints(non_negative, float)   -> [{min, +0.0}];
expand_constraints(positive, integer) -> [{min, 1}];
expand_constraints(positive, float)   -> [{gt, +0.0}];
%% `allow_infinity' is consumed by `maybe_infinity'; drop it here so
%% the numeric path doesn't try to evaluate it as a bound.
expand_constraints(allow_infinity, _) -> [];
expand_constraints(Constraints, BaseType) when is_list(Constraints) ->
    lists:flatmap(fun(C) -> expand_constraints(C, BaseType) end, Constraints);
expand_constraints(C, _BaseType) -> [C].

check_constraints(Value, []) -> Value;
check_constraints(Value, [{validator, Fun} | Rest]) when is_function(Fun, 1) ->
    case run_validator(Fun, Value) of
        true  -> check_constraints(Value, Rest);
        false -> {error, {constraint_validator_failed, Value}}
    end;
%% A string-form name here means name resolution did not run; surface
%% the orphan rather than silently treating it as a pass.
check_constraints(_Value, [{validator, Name} | _]) when is_list(Name) ->
    {error, {constraint_validator_unresolved, Name}};
check_constraints(Value, [Constraint | Rest]) ->
    case satisfies(Value, Constraint) of
        true  -> check_constraints(Value, Rest);
        false -> {error, {range_violation, {Value, Constraint}}}
    end.

%% Validator funs may throw; treat any exception as a failure rather
%% than letting it escape into the surrounding pipeline.
run_validator(Fun, Value) ->
    try Fun(Value) of
        true -> true;
        _    -> false
    catch
        _:_ -> false
    end.

satisfies(V, {min, N}) -> V >= N;
satisfies(V, {max, N}) -> V =< N;
satisfies(V, {gt,  N}) -> V >  N;
satisfies(V, {lt,  N}) -> V <  N.

%% Collects every string-form name referenced as `{validator, Name}'
%% inside any constraint list in `DT'. Returned sorted and unique so
%% schema-time pre-flight checks can run against a stable set —
%% catching a typo at schema load rather than at config generation.
-spec constraint_validator_refs(datatype() | [datatype()]) -> [string()].
constraint_validator_refs(DT) ->
    lists:usort(collect_refs(DT)).

collect_refs(L) when is_list(L) ->
    lists:flatmap(fun collect_refs/1, L);
collect_refs({integer, Cs}) -> collect_constraint_refs(Cs);
collect_refs({float, Cs})   -> collect_constraint_refs(Cs);
collect_refs({bytesize, Cs}) -> collect_constraint_refs(Cs);
collect_refs({duration, _Unit, Cs}) -> collect_constraint_refs(Cs);
collect_refs({list, Inner}) -> collect_refs(Inner);
collect_refs(_) -> [].

collect_constraint_refs(L) when is_list(L) ->
    [N || {validator, N} <- L, is_list(N)];
collect_constraint_refs(_) -> [].

%% Walks the datatype expression and replaces each `{validator, Name}'
%% inside a constraint list with the resolved `{validator, Fun}'.
%% Names that don't resolve are left in place; the runtime constraint
%% pipeline surfaces them as `{constraint_validator_unresolved, Name}'.
%% Refs are expected to have been validated up front, so an unresolved
%% name reaching the runtime indicates a genuinely orphaned reference.
-spec resolve_constraint_validators(
        datatype() | [datatype()],
        fun((string()) -> {ok, validator_fun()} | not_found)) ->
    datatype() | [datatype()].
resolve_constraint_validators(DT, Lookup) ->
    walk_resolve(DT, Lookup).

walk_resolve(L, Lookup) when is_list(L) ->
    [walk_resolve(E, Lookup) || E <- L];
walk_resolve({integer, Cs}, Lookup) ->
    {integer, resolve_cs(Cs, Lookup)};
walk_resolve({float, Cs}, Lookup) ->
    {float, resolve_cs(Cs, Lookup)};
walk_resolve({bytesize, Cs}, Lookup) ->
    {bytesize, resolve_cs(Cs, Lookup)};
walk_resolve({duration, Unit, Cs}, Lookup) ->
    {duration, Unit, resolve_cs(Cs, Lookup)};
walk_resolve({list, Inner}, Lookup) ->
    {list, walk_resolve(Inner, Lookup)};
walk_resolve(Other, _Lookup) -> Other.

resolve_cs(Cs, Lookup) when is_list(Cs) ->
    [resolve_c(C, Lookup) || C <- Cs];
resolve_cs(C, _Lookup) -> C.

resolve_c({validator, Name}, Lookup) when is_list(Name) ->
    case Lookup(Name) of
        {ok, Fun} when is_function(Fun, 1) -> {validator, Fun};
        _ -> {validator, Name}
    end;
resolve_c(C, _Lookup) -> C.

-ifdef(TEST).

-define(XLATE(X), lists:flatten(cuttlefish_error:xlate(X))).

to_string_atom_test() ->
    ?assertEqual("split_the", to_string(split_the, atom)),
    ?assertEqual("split_the", to_string("split_the", atom)).

to_string_integer_test() ->
    ?assertEqual("32", to_string(32, integer)),
    ?assertEqual("32", to_string("32", integer)).

to_string_ip_test() ->
    ?assertEqual("127.0.0.1:8098", to_string("127.0.0.1:8098", ip)),
    ?assertEqual("127.0.0.1:8098", to_string({"127.0.0.1", 8098}, ip)).

to_string_enum_test() ->
    ?assertEqual("true", to_string("true", {enum, [true, false]})),
    ?assertEqual("true", to_string(true, {enum, [true, false]})).

to_string_string_test() ->
    ?assertEqual("string", to_string("string", string)).

to_string_duration_test() ->
    ?assertEqual("1w", to_string("1w", {duration, s})),
    ?assertEqual("1w", to_string(604800000, {duration, ms})).

to_string_bytesize_test() ->
    ?assertEqual("1GB", to_string(1073741824, bytesize)),
    ?assertEqual("1GB", to_string("1GB", bytesize)).

to_string_percent_integer_test() ->
    ?assertEqual("10%", to_string(10, {percent, integer})),
    ?assertEqual("10%", to_string("10%", {percent, integer})),
    ok.

to_string_percent_float_test() ->
    ?assertEqual("10%", to_string(0.1, {percent, float})),
    ?assertEqual("10%", to_string("10%", {percent, float})),
    ok.

to_string_float_test() ->
    ?assertEqual("0.1", to_string(0.1, float)),
    ?assertEqual("0.1", to_string("0.1", float)),
    ok.

to_string_extended_type_test() ->
    ?assertEqual("split_the", to_string(split_the, {atom, split_the})),
    ?assertEqual("split_the", to_string("split_the", {atom, split_the})),
    ?assertEqual("32", to_string(32, {integer, 32})),
    ?assertEqual("32", to_string("32", {integer, 32})),
    ?assertEqual("127.0.0.1:8098", to_string("127.0.0.1:8098", {ip, "127.0.0.1:8098"})),
    ?assertEqual("127.0.0.1:8098", to_string({"127.0.0.1", 8098}, {ip, {"127.0.0.1", 8098}})),
    ?assertEqual("example.com:8098", to_string("example.com:8098", {fqdn, "example.com:8098"})),
    ?assertEqual("example.com:8098", to_string({"example.com", 8098}, {fqdn, {"example.com", 8098}})),
    ?assertEqual("local:/path/test.sock:0",
                 to_string("local:/path/test.sock:0", {domain_socket, "local:/path/test.sock:0"})),
    ?assertEqual("unix:/path/test.sock:0",
                 to_string("unix:/path/test.sock:0", {domain_socket, "unix:/path/test.sock:0"})),
    ?assertEqual("local:/path/test.sock:0",
                 to_string({local, "/path/test.sock", 0}, {domain_socket, {local, "/path/test.sock", 0}})),
    ?assertEqual("local:/path/test.sock:0",
                 to_string({local, "/path/test.sock", 0}, {domain_socket, {local, "/path/test.sock", 0}})),
    ?assertEqual("string", to_string("string", {string, "string"})),
    ?assertEqual("1w", to_string("1w", {{duration, s}, "1w"})),
    ?assertEqual("1w", to_string(604800000, {{duration, ms}, "1w"})),
    ?assertEqual("1GB", to_string(1073741824, {bytesize, "1GB"})),
    ?assertEqual("1GB", to_string("1GB", {bytesize, "1GB"})),
    ?assertEqual("10%", to_string(10, {{percent, integer}, "10%"})),
    ?assertEqual("10%", to_string("10%", {{percent, integer}, "10%"})),
    ?assertEqual("10%", to_string(0.1, {{percent, float}, "10%"})),
    ?assertEqual("10%", to_string("10%", {{percent, float}, "10%"})),
    ?assertEqual("0.1", to_string(0.1, {float, 0.1})),
    ?assertEqual("0.1", to_string("0.1", {float, 0.1})),
    ok.

to_string_unsupported_datatype_test() ->
    ?assertEqual("Tried to convert \"Something\" but invalid datatype: unsupported_datatype", ?XLATE(to_string("Something", unsupported_datatype))).

from_string_atom_test() ->
    ?assertEqual(split_the, from_string(split_the, atom)),
    ?assertEqual(split_the, from_string("split_the", atom)).

from_string_integer_test() ->
    ?assertEqual(32, from_string(32, integer)),
    ?assertEqual(32, from_string("32", integer)),
    ?assertEqual("\"thirty_two\" cannot be converted to a(n) integer", ?XLATE(from_string("thirty_two", integer))),
    ok.

from_string_ip_test() ->
    ?assertEqual({"127.0.0.1", 8098}, from_string("127.0.0.1:8098", ip)),
    ?assertEqual(
        {"2001:0db8:85a3:0042:1000:8a2e:0370:7334", 8098},
        from_string("2001:0db8:85a3:0042:1000:8a2e:0370:7334:8098", ip)),
    ?assertEqual(
        {"2001:0db8:85a3::0370:7334", 8098},
        from_string("2001:0db8:85a3::0370:7334:8098", ip)),
    ?assertEqual(
        {"::1", 1},
        from_string("::1:1", ip)),

    BadIPs = [
              "This is not an IP:80",
              "2001:0db8:85a3:0042:1000:8a2e:0370:80",
              "127.0.0.1.1:80",
              "127.256.0.1:80",
              "127.0.0.1", %% No port
              "127.0.0.1:-5",
              "0:127.0.0.1:80",
              "127.0.0.1:80l",
              ":1:1"
             ],

    lists:foreach(fun(Bad) ->
                          ?assertEqual({error, {conversion, {Bad, 'IP'}}},
                                       from_string(Bad, ip))
                  end,
                  BadIPs),
    ok.

from_string_fqdn_test() ->
    ?assertEqual({"fqdn.com", 8098}, from_string("fqdn.com:8098", fqdn)),
    ?assertEqual(
        {"f.q.d.n.com", 8098},
        from_string("f.q.d.n.com:8098", fqdn)),
    ?assertEqual(
        {"fqdn.com.", 8098},
        from_string("fqdn.com.:8098", fqdn)),
    ?assertEqual(
        {"FqDn.com", 8098},
        from_string("FqDn.com:8098", fqdn)),
    ?assertEqual(
        {"ec2-35-160-210-253.us-west-2-.compute.amazonaws.com", 1},
        from_string("ec2-35-160-210-253.us-west-2-.compute.amazonaws.com:1", fqdn)),

    BadFQDNs = [
                "This is not an fqdn:80",
                "This.is not.an.fqdn:80",
                "",
                "127.0.0.1:80",
                "fqdn.com", %% No port
                "fqdn.com:-5",
                "fqdn.com:80:81"
               ],

    lists:foreach(fun(Bad) ->
                          ?assertEqual({error, {conversion, {Bad, 'FQDN'}}},
                                       from_string(Bad, fqdn))
                  end,
                  BadFQDNs),
    ok.

from_string_domain_socket_test() ->
    ?assertEqual({local, "/tmp/test.sock", 0}, from_string("local:/tmp/test.sock:0", domain_socket)),
    ?assertEqual({local, "/tmp/test.sock", 0}, from_string("unix:/tmp/test.sock:0", domain_socket)),
    ?assertEqual({local, "test.sock", 0}, from_string("local:test.sock:0", domain_socket)),
    ?assertEqual({local, "/run/不亦樂乎.sock", 0}, from_string("local:/run/不亦樂乎.sock:0", domain_socket)),

    BadUDSs = [
            "local:/tmp/test.sock", %% No port
            "local:/tmp/test.sock:80", %% Non 0 port
            "/tmp/test.sock", %% No local prefix
            "",
            "local:/tmp/test.sock:foo", %% invalid port
            "local:/tmp/test.sock:0:0", %% double port
            "prefix:foo.sock:0" %% Bad prefix
            ],

    lists:foreach(fun(Bad) ->
                          ?assertEqual({error, {conversion, {Bad, 'UDS'}}},
                                       from_string(Bad, domain_socket))
                  end,
                  BadUDSs),
    ok.

from_string_enum_test() ->
    ?assertEqual("\"a\" is not a valid enum value, acceptable values are: b, c", ?XLATE(from_string(a, {enum, [b, c]}))),
    ?assertEqual(true, from_string("true", {enum, [true, false]})),
    ?assertEqual(true, from_string(true, {enum, [true, false]})).

from_string_duration_test() ->
    %% more examples in the the cuttlefish_duration tests
    ?assertEqual(1100, from_string("1s100ms", {duration, ms})),
    ?assertEqual(1100, from_string(1100, {duration, ms})),
    ok.

from_string_duration_secs_test() ->
    %% more examples in the the cuttlefish_duration tests
    %% also rounds up for smaller units
    ?assertEqual(2, from_string("1s100ms", {duration, s})),
    ?assertEqual(2, from_string(2, {duration, s})),
    ok.

from_string_percent_integer_test() ->
    ?assertEqual(10, from_string("10%", {percent, integer})),
    ?assertEqual(10, from_string(10, {percent, integer})),
    %% Range!
    ?assertEqual(0, from_string("0%", {percent, integer})),
    ?assertEqual(100, from_string("100%", {percent, integer})),
    ?assertEqual("110% can't be outside the range 0 - 100%", ?XLATE(from_string("110%", {percent, integer}))),
    ?assertEqual("-1% can't be outside the range 0 - 100%", ?XLATE(from_string("-1%", {percent, integer}))),
    ok.

from_string_percent_float_test() ->
    ?assertEqual(0.10, from_string("10%", {percent, float})),
    ?assertEqual(0.10, from_string(0.1, {percent, float})),
    %% Range!
    ?assertEqual(0.0, from_string("0%", {percent, float})),
    ?assertEqual(1.0, from_string("100%", {percent, float})),
    ?assertEqual("110% can't be outside the range 0 - 100%", ?XLATE(from_string("110%", {percent, float}))),
    ?assertEqual("-1% can't be outside the range 0 - 100%", ?XLATE(from_string("-1%", {percent, float}))),
    ok.

from_string_float_test() ->
    ?assertEqual(0.1, from_string("0.100", float)),
    ?assertEqual(0.1, from_string(0.1, float)),
    ok.

from_string_string_test() ->
    ?assertEqual("string", from_string("string", string)).

from_string_tagged_string_test() ->
    ?assertEqual({prefixed, "string"}, from_string("prefixed:string", tagged_string)),
    ?assertEqual({encrypted, "str:ng"}, from_string("encrypted:str:ng", tagged_string)),

    %% the tag (prefix) is not alphanumeric, reject such values
    Val1 = "a$$b12:12323",
    ?assertEqual({error,{conversion,{Val1, "tagged string"}}}, from_string(Val1, tagged_string)),
    ?assertMatch({error, {conversion, _}}, from_string(":value", tagged_string)),
    ?assertMatch({error, {conversion, _}}, from_string("prefixed:", tagged_string)).

from_string_tagged_binary_test() ->
    ?assertEqual({prefixed, <<"binary">>}, from_string("prefixed:binary", tagged_binary)),
    ?assertEqual({encrypted, <<"str:ng">>}, from_string("encrypted:str:ng", tagged_binary)),

    %% the tag (prefix) is not alphanumeric, reject such values
    Val1 = "a$$b12:12323",
    ?assertEqual({error,{conversion,{Val1, "tagged binary"}}}, from_string(Val1, tagged_binary)),
    ?assertMatch({error, {conversion, _}}, from_string(":value", tagged_binary)),
    ?assertMatch({error, {conversion, _}}, from_string("prefixed:", tagged_binary)).

from_string_string_list_test() ->
    %% more examples in the the cuttlefish_duration tests
    ?assertEqual(["v1", "v2", "v3"], from_string("v1, v2,v3", {list, string})),
    ok.

from_string_integer_list_test() ->
    %% more examples in the the cuttlefish_duration tests
    ?assertEqual([1, 2, 3], from_string("1, 2,3", {list, integer})),
    ok.

from_string_atom_list_test() ->
    %% more examples in the the cuttlefish_duration tests
    ?assertEqual([a, b, c], from_string("a, b,c", {list, atom})),
    ok.

from_string_string_in_integer_list_test() ->
    %% more examples in the the cuttlefish_duration tests
    ?assertEqual([{error, {conversion, {"a", integer}}}, 1, 2],
                 from_string("a, 1,2", {list, integer})),
    ok.

from_string_unsupported_datatype_test() ->
    ?assertEqual("Tried to convert \"string\" but invalid datatype: unsupported_datatype", ?XLATE(from_string("string", unsupported_datatype))).

is_supported_test() ->
    ?assert(is_supported(integer)),
    ?assert(is_supported(string)),
    ?assert(is_supported(binary)),
    ?assert(is_supported(atom)),
    ?assert(is_supported(file)),
    ?assert(is_supported(directory)),
    ?assert(is_supported({enum, [one, two, three]})),
    ?assert(not(is_supported({enum, not_a_list}))),
    ?assert(is_supported(ip)),
    ?assert(is_supported({duration, f})),
    ?assert(is_supported({duration, w})),
    ?assert(is_supported({duration, d})),
    ?assert(is_supported({duration, h})),
    ?assert(is_supported({duration, m})),
    ?assert(is_supported({duration, s})),
    ?assert(is_supported({duration, ms})),
    ?assert(is_supported(bytesize)),
    ?assert(is_supported(domain_socket)),
    ?assert(is_supported(tagged_string)),
    ?assert(is_supported(tagged_binary)),
    ?assert(is_supported({list, string})),
    ?assert(not(is_supported({list, {list, string}}))),
    ?assert(not(is_supported(some_unsupported_type))),
    ok.

is_extended_test() ->
    ?assertEqual(true, is_extended({integer, 10})),
    ?assertEqual(true, is_extended({integer, -10})),
    ?assertEqual(false, is_extended({integer, "ten"})),

    ?assertEqual(true, is_extended({string, "string"})),
    ?assertEqual(false, is_extended({string, string})),
    ?assertEqual(false, is_extended({string, 10})),

    ?assertEqual(true, is_extended({binary, "string"})),
    ?assertEqual(false, is_extended({binary, string})),
    ?assertEqual(false, is_extended({binary, 10})),

    ?assertEqual(true, is_extended({atom, atom})),
    ?assertEqual(false, is_extended({atom, "atom"})),
    ?assertEqual(false, is_extended({atom, 10})),

    ?assertEqual(true, is_extended({file, "/tmp/foo.txt"})),
    ?assertEqual(true, is_extended({file, ""})),
    ?assertEqual(false, is_extended({file, this})),

    ?assertEqual(true, is_extended({directory, "/tmp/foo.txt"})),
    ?assertEqual(true, is_extended({directory, ""})),
    ?assertEqual(false, is_extended({directory, this})),

    ?assertEqual(true, is_extended({ip, {"1.2.3.4", 1234}})),
    ?assertEqual(false, is_extended({ip, {1234, 1234}})),
    ?assertEqual(false, is_extended({ip, {"1.2.3.4", "1234"}})),

    ?assertEqual(true, is_extended({domain_socket, {local, "test.sock", 1234}})),
    ?assertEqual(false, is_extended({domain_socket, {local, "test.sock", "1234"}})),
    ?assertEqual(false, is_extended({domain_socket, {local, 1234, 1234}})),
    ?assertEqual(false, is_extended({domain_socket, {local, "foo"}})),

    ?assertEqual(true, is_extended({{duration, f}, "10f"})),
    ?assertEqual(true, is_extended({{duration, w}, "10f"})),
    ?assertEqual(true, is_extended({{duration, d}, "10f"})),
    ?assertEqual(true, is_extended({{duration, g}, "10f"})),
    ?assertEqual(true, is_extended({{duration, m}, "10f"})),
    ?assertEqual(true, is_extended({{duration, s}, "10f"})),
    ?assertEqual(true, is_extended({{duration, ms}, "10ms"})),
    ?assertEqual(true, is_extended({bytesize, "10GB"})),

    ?assertEqual(true, is_extended({{percent, integer}, "10%"})),
    ?assertEqual(true, is_extended({{percent, integer}, 10})),
    ?assertEqual(true, is_extended({{percent, float}, "10%"})),
    ?assertEqual(true, is_extended({{percent, float}, 0.1})),
    ?assertEqual(true, is_extended({float, 0.1})),

    ?assertEqual(true, is_extended({tagged_string, "tag:value"})),
    ?assertEqual(true, is_extended({tagged_binary, "tag:value"})),

    ok.

-endif.
