src/erlPass.erl

%% @doc A password generator.
%% @author Robert Lasu
%% @copyright 2022 Robert Lasu <robert.lasu@gmail.com>

-module(erlPass).
-include_lib("eunit/include/eunit.hrl").

-define(TBI(Msg), exit(self(), {tbi, Msg})).
-define(ERR(Msg), {error, {?MODULE, ?LINE, Msg}}).

-export([generate/1, generate/2]).

gen_number({_A,B}) when not(is_integer(B)) -> ?ERR(not_number);
gen_number({_A,B}) when is_integer(B) and not(B < 0) -> integer_to_list(B rem 9);
gen_number(_) -> ?ERR(badargs).

gen_upper({_A,B}) when not(is_integer(B)) -> ?ERR(not_number);
gen_upper({_A,B}) when is_integer(B) and not(B < 0) -> [65 + (B rem 26)];
gen_upper(_) -> ?ERR(badargs).

gen_lower({_A,B}) when not(is_integer(B)) -> ?ERR(not_number);
gen_lower({_A,B}) when is_integer(B) and not(B < 0) -> [97 + (B rem 26)];
gen_lower(_) -> ?ERR(badargs).

gen_symbol({A, B}) when not(is_integer(A)) and not(is_integer(B)) -> ?ERR(not_number);
gen_symbol({A, B}) when (B rem 4 == 0) and not(A < 0) -> [32 + A rem 16];
gen_symbol({A, B}) when (B rem 4 == 1) and not(A < 0) -> [58 + A rem 7];
gen_symbol({A, B}) when (B rem 4 == 2) and not(A < 0) -> [91 + A rem 6];
gen_symbol({A, B}) when (B rem 4 == 3) and not(A < 0) -> [123 + A rem 4];
gen_symbol(_) -> ?ERR(badargs).

seed() ->
    <<A:24, B:24>> = crypto:strong_rand_bytes(6),
    {A,B}.

ops() -> #{upper => 0, lower => 0, number => 0, symbol => 0}.

get_amount(Map) -> lists:foldl(fun(X, Sum) -> X + Sum end, 0, maps:values(Map)).

validate_ops(Len, Map, Nr) when Nr >= Len -> Map;
validate_ops(_, _, _) -> ?ERR(to_few_chars).

create_ops_list(Len, [], Map) -> validate_ops(Len, Map, get_amount(Map));
create_ops_list(Len, [H | Tail], Map) when is_atom(H)-> 
    case maps:is_key(H, Map) of
        true ->
            create_ops_list(Len, Tail, maps:update(H, Len, Map));
        false ->
            ?ERR(invalid_options)
        end;
create_ops_list(Len, [{Ops, Nr} | Tail], Map) when (is_number(Nr)) and (Nr >= 0) ->
    case maps:is_key(Ops, Map) of
        true ->
            create_ops_list(Len, Tail, maps:update(Ops, Nr, Map));
        false ->
            ?ERR(invalid_options)
        end;
create_ops_list(_, [{_,Nr} | _], _) when not(is_number(Nr))-> ?ERR(not_number);
create_ops_list(_,_,_) -> ?ERR(badargs).

generate(Len, OpsMap, Pass, Fun, Op) ->
    case maps:get(Op, OpsMap) of
        0 ->
            generate(Len, OpsMap, Pass, seed());
        Nr ->
            NewMap = maps:update(Op, Nr-1, OpsMap),
            generate(Len-1, NewMap, [Fun(seed()) | Pass], seed())
        end.

generate(_, Err = {error, _}, _, _) -> Err;
generate(0, _, Pass, _) -> lists:flatten(Pass);
generate(Len, OpsMap, Pass, {A,_B}) when A rem 4 == 0 -> generate(Len, OpsMap, Pass, fun gen_number/1, number);
generate(Len, OpsMap, Pass, {A,_B}) when A rem 4 == 1 -> generate(Len, OpsMap, Pass, fun gen_upper/1, upper);
generate(Len, OpsMap, Pass, {A,_B}) when A rem 4 == 2 -> generate(Len, OpsMap, Pass, fun gen_lower/1, lower);
generate(Len, OpsMap, Pass, {A,_B}) when A rem 4 == 3 -> generate(Len, OpsMap, Pass, fun gen_symbol/1, symbol).

%% @doc Generates a password with length Len and options OpsList
%%
%% `generate/2' takes the length of password and a list of options.
%% OpsList must contaion the atoms `upper', `lower', `number', `symbol' and/or
%% tuples on the format `{upper, 2}', representing one of the atoms above and the maximum number of occurrences.
%% @end
-spec generate(Len, OpsList) -> Password | {error, Reason} when
    Len         :: integer(),
    OpsList     :: list(),
    Password    :: list(),
    Reason      :: term().
generate(_,[]) -> ?ERR(no_options);
generate(Len,_) when Len < 1 -> ?ERR(invalid_length);
generate(Len, List) -> generate(Len, create_ops_list(Len, List, ops()), [], seed()).


%% @doc Generates a password with length Len.
%% 
%% Calling `generate(10)'
%% is the same as calling
%%
%% `generate(10, [upper, lower, number, symbol])'.
%% 
%% @see generate/2
%% @end
generate(Len) -> generate(Len, [upper, lower, number, symbol]).

%% ----------------------------
%%          Unit Tests
%% ----------------------------
%% @private
gen_number_test() ->
    {error, {_,_,R1}} = gen_number({-1,-3}),
    ?assert(R1 =:= badargs),
    {error, {_,_,R2}} = gen_number({sd,tq}),
    ?assert(R2 =:= not_number),
    ?assert(gen_number({0, 123}) =:= "6").
%% @private
gen_upper_test() ->
    {error, {_, _, R1}} = gen_upper({-1,-1}),
    ?assert(R1 =:= badargs),
    {error, {_, _, R2}} = gen_upper({w,d}),
    ?assert(R2 =:= not_number),
    ?assert(gen_upper({32,123}) =:= "T").
%% @private
gen_lower_test() ->
    {error, {_,_,R1}} = gen_lower({-32,-4}),
    ?assert(R1 =:= badargs),
    {error, {_,_,R2}} = gen_lower({as,fas}),
    ?assert(R2 =:= not_number),
    ?assert(gen_lower({12,123}) =:= "t").
%% @private
gen_symbol_test() ->
    {error, {_,_,R1}} = gen_symbol({-32,-1}),
    ?assert(R1 =:= badargs),
    {error, {_,_,R2}} = gen_symbol({dsa,d}),
    ?assert(R2 =:= not_number),
    ?assert(gen_symbol({123, 321}) =:= ">"),
    {error, {_,_,R3}} = gen_symbol({"Warden", clyff}),
    ?assert(R3 =:= not_number).
%% @private
create_ops_list_test() ->
    Ops1 = create_ops_list(10, [upper], ops()),
    ?assert(maps:get(upper, Ops1) =:= 10),
    {error, {_,_,R1}} = create_ops_list(10, [this_is_wrong], ops()),
    ?assert(R1 =:= invalid_options),
    {error, {_,_,R2}} = create_ops_list(10, [{fah,10}], ops()),
    ?assert(R2 =:= invalid_options),
    {error, {_,_,R3}} = create_ops_list(32, [{upper, "32"}], ops()),
    ?assert(R3 =:= not_number).

%% ----------------------------
%%      Integration Tests
%% ----------------------------
%% @private
generate_test() ->
    Pass = generate(100, [upper, lower, symbol, number]),
    generate(10, [lower, number]),
    ?assert(length(Pass) =:= 100),
    {error, {_,_,R1}} = generate(5,[]),
    ?assert(R1 =:= no_options),
    {error, {_,_,R2}} = generate(4, {upper,2}),
    ?assert(R2 =:= badargs),
    _Pass2 = generate(50, [{upper,1}, lower, {number,2}, {symbol,9}]),
    {error, {_,_,R3}} = generate(0),
    ?assert(R3 =:= invalid_length).