%% -------------------------------------------------------------------
%%
%% cuttlefish_conf: handles the reading and generation of .conf files
%%
%% 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_conf).

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

-export([
         generate/1,
         generate_file/2,
         file/1,
         files/1,
         is_variable_defined/2,
         pretty_datatype/1]).

-type conf_pair() :: {cuttlefish_variable:variable(), any()}.
-type conf() :: [conf_pair()].
-export_type([conf_pair/0, conf/0]).

-define(FMT(F, Args), lists:flatten(io_lib:format(F, Args))).

is_variable_defined(VariableDef, Conf) ->
    lists:any(fun({X, _}) -> cuttlefish_variable:is_fuzzy_match(X, VariableDef) end, Conf).

-spec files([file:name()]) -> conf() | cuttlefish_error:errorlist().
files(ListOfConfFiles) ->
    {ValidConf, Errors} = lists:foldl(
      fun(ConfFile, {ConfAcc, ErrorAcc}) ->
              case cuttlefish_conf:file(ConfFile) of
                  {errorlist, ErrorList} ->
                      {ConfAcc, ErrorList ++ ErrorAcc};
                  Conf ->
                      {lists:foldl(
                        fun({K,V}, MiniAcc) ->
                                cuttlefish_util:replace_proplist_value(K, V, MiniAcc)
                        end,
                        ConfAcc,
                        Conf), ErrorAcc}
              end
      end,
      {[], []},
      ListOfConfFiles),
    case {ValidConf, Errors} of
        {_, []} -> ValidConf;
        _ -> {errorlist, Errors}
    end.

-spec file(file:name()) -> conf() | cuttlefish_error:errorlist().
file(Filename) ->
    case conf_parse:file(Filename) of
        {error, Reason} ->
            %% Reason is an atom via file:open
            {errorlist, [{error, {file_open, {Filename, Reason}}}]};
        {_Conf, Remainder, {{line, L}, {column, C}}} when is_binary(Remainder) ->
            {errorlist, [{error, {conf_syntax, {Filename, {L, C}}}}]};
        Conf0 ->
            % go through the conf looking for include directives, fold all into
            % a single proplist
            Conf = fold_conf_files(Filename, Conf0),
            %% Conf is a proplist, check if any of the values are cuttlefish_errors
            {_, Values} = lists:unzip(Conf),
            case cuttlefish_error:filter(Values) of
                {errorlist, []} ->
                    % expand any non-literal values (ie. included values)
                    expand_values(Filename, remove_duplicates(Conf));
                {errorlist, ErrorList} ->
                    NewErrorList = [ {error, {in_file, {Filename, E}}} || {error, E} <- ErrorList ],
                    {errorlist, NewErrorList}
            end
    end.

fold_conf_files(Filename, Conf0) ->
    lists:foldl(fun({include, Included}, Acc) ->
                        DirName = binary_to_list(filename:join(filename:dirname(Filename),
                                                               filename:dirname(Included))),
                        BaseName = binary_to_list(filename:basename(Included)),
                        Acc ++ lists:flatten(
                                  [file(filename:join(DirName, F)) || F <-
                                                    filelib:wildcard(BaseName, DirName)]);
                    (KeyValue, Acc) ->
                        Acc ++ [KeyValue]
                end,
                [],
                Conf0).

expand_values(Filename, Conf) ->
    lists:map(fun({K, Value0}) ->
                case re:run(Value0, "^\\$\\(\\<(.+)\\)$", [unicode, {capture, all_but_first, list}]) of
                    {match, [IncludeFilename0]} ->
                        % This is a value of the format "$(<IncludeFilename)", let's read the contents
                        % of `IncludeFilename` and use that as the new value
                        %
                        % strip all space chars from beginning/end and join the relative filename with the
                        % location of the sourcing .conf file
                        IncludeFilename = filename:join([filename:dirname(Filename),
                                                         re:replace(IncludeFilename0, "(^\\s+)|(\\s+$)", "", [unicode, {return, list}])]),
                        % read the entire file contents and strip newline
                        case file:read_file(IncludeFilename) of
                          {ok, Value} ->
                            {K, re:replace(Value, "[\n\r]$", "", [unicode, {return, list}])};
                          {error, Reason} ->
                            throw({unable_to_open, IncludeFilename, Reason})
                        end;
                    _ ->
                        % normal value, nothing to do
                        {K, Value0}
                end
              end,
              Conf).

-spec generate([cuttlefish_mapping:mapping()]) -> [string()].
generate(Mappings) ->
    lists:foldl(
      fun(Mapping, ConfFile) ->
              ConfFile ++ generate_element(Mapping)
      end, [], Mappings).

-spec generate_file([cuttlefish_mapping:mapping()], string()) -> ok.
generate_file(Mappings, Filename) ->
    ConfFileLines = generate(Mappings),

    {ok, S} = file:open(Filename, [write]),
    _ = [ begin
          io:format(S, "~ts~n", [lists:flatten(Line)])
      end || Line <- ConfFileLines],
    % add an include directive at the end that will allow
    % other conf files in `conf.d` and have them get picked up
    % in order to override settings
    % example use case is a k8s configMap that is mapped as a file
    % to conf.d/erlang_vm.conf
    io:format(S, "include conf.d/*.conf~n~n", []),
    _ = file:close(S),
    ok.

-spec generate_element(cuttlefish_mapping:mapping()) -> [string()].
generate_element(MappingRecord) ->
    Default = get_default(MappingRecord),
    Key = cuttlefish_mapping:variable(MappingRecord),
    Commented = cuttlefish_mapping:commented(MappingRecord),
    Level = cuttlefish_mapping:level(MappingRecord),
    Hidden = cuttlefish_mapping:hidden(MappingRecord),
    IncDef = cuttlefish_mapping:include_default(MappingRecord),
    [Datatype|_] = cuttlefish_mapping:datatype(MappingRecord),
    %% level != basic OR hidden == true: leave out of generated .conf file
    %% commented $val: insert into .conf file, but commented out with $val
    %% include_default $val:  substitute '$name' or whatever in the key for $val
    %%    e.g. {include_default, "internal"}
    %%         listener.http.$name -> listener.http.internal

    Field = cuttlefish_variable:format(cuttlefish_variable:replace_match(Key, IncDef)),

    case Level of
        basic -> ok;
        Level ->
            _ = ?LOG_WARNING("{level, ~tp} has been deprecated. Use 'hidden' or '{hidden, true}'", [Level])
    end,

    case generate_element(Hidden, Level, Default, Commented) of
        no ->
            [];
        commented ->
            Comments = generate_comments(MappingRecord),
            Comments ++ [lists:flatten([ "## ", Field, " = ", cuttlefish_datatypes:to_string(Commented, Datatype) ]), ""];
        default ->
            Comments = generate_comments(MappingRecord),
            Comments ++ [lists:flatten([ Field, " = ", cuttlefish_datatypes:to_string(Default, Datatype) ]), ""]
    end.

get_default(MappingRecord) ->
    %% Normally we use `default` to determine what value to use when generating
    %% a config file, but `new_conf_value` can override that. The reason we need
    %% a separate attribute to override `default` (instead of just changing the
    %% default directly) is that `default` also affects default values used for
    %% config keys that haven't been set to any particular value in the .conf file.
    %% (See `cuttlefish_generator:add_defaults` for the relevant bits of code.)
    case cuttlefish_mapping:new_conf_value(MappingRecord) of
        undefined ->
            cuttlefish_mapping:default(MappingRecord);
        Value ->
            Value
    end.

generate_element(true, _, _, _) -> no;
generate_element(false, _, undefined, undefined) -> no;
generate_element(false, basic, _Default, undefined) -> default;
generate_element(false, basic, _, _Comment) -> commented;
generate_element(false, _Level, _Default, _Commented) -> no.

-spec generate_comments(cuttlefish_mapping:mapping()) -> [string()].
generate_comments(M) ->
    DocString = cuttlefish_mapping:doc(M),

    [DefaultDT|_] = cuttlefish_mapping:datatype(M),
    Default = case cuttlefish_mapping:default(M) of
                  undefined -> [];
                  Other ->
                      [ "", ?FMT("Default: ~ts", [cuttlefish_datatypes:to_string(Other, DefaultDT)]) ]
              end,

    Datatypes = ["", "Acceptable values:" |
                 [ ?FMT("  - ~ts", [pretty_datatype(DT)])
                   || DT <- cuttlefish_mapping:datatype(M)]],

    Doc = DocString ++ Default ++ Datatypes,
    [ "## " ++ D || D <- Doc].

-spec pretty_datatype(cuttlefish_datatypes:datatype() |
                      cuttlefish_datatypes:extended()) -> string().
pretty_datatype(integer) -> "an integer";
pretty_datatype({enum, L}) ->
    "one of: " ++ string:join([ atom_to_list(A) || A <- L], ", ");
pretty_datatype(ip) -> "an IP/port pair, e.g. 127.0.0.1:10011";
pretty_datatype(domain_socket) -> "a Unix Domain Socket, e.g. local:/var/run/app.sock:0";
pretty_datatype({duration, _}) -> "a time duration with units, e.g. '10s' for 10 seconds";
pretty_datatype(bytesize) -> "a byte size with units, e.g. 10GB";
pretty_datatype({integer, I}) -> "the integer " ++ integer_to_list(I);
pretty_datatype({string, S}) -> "the text \"" ++ S ++ "\"";
pretty_datatype({tagged_string, {Tag, String}}) -> "the text \"" ++ String ++ "\"" ++ " tagged as \"" ++ Tag ++ "\"";
pretty_datatype({atom, A}) -> "the text \"" ++ atom_to_list(A) ++ "\"";
pretty_datatype({ip, {IP, Port}}) -> ?FMT("the address ~ts:~tp", [IP, Port]);
pretty_datatype({domain_socket, {local, Path, Port}}) ->
    ?FMT("the Unix Domain Socket ~ts:~tp", [Path, Port]);
pretty_datatype({{duration,_}, D}) -> "the time duration " ++ D;
pretty_datatype({bytesize, B}) -> "the bytesize " ++ B;
pretty_datatype(file) -> "the path to a file";
pretty_datatype(directory) -> "the path to a directory";
pretty_datatype({file, F}) -> "the file " ++ F;
pretty_datatype({directory, D}) -> "the directory " ++ D;
pretty_datatype(flag) -> "on or off";
pretty_datatype({flag, On, Off}) when is_atom(On), is_atom(Off) ->
    ?FMT("~tp or ~tp", [On, Off]);
pretty_datatype({flag, {On,_}, {Off,_}}) ->
    ?FMT("~tp or ~tp", [On, Off]);
pretty_datatype(_) -> "text". %% string and atom

remove_duplicates(Conf) ->
    lists:foldl(
      fun({K,V}, MiniAcc) ->
              cuttlefish_util:replace_proplist_value(K, V, MiniAcc)
      end,
      [],
      Conf).

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

-ifdef(TEST).

generate_element_test() ->

    TestSchemaElement =
        cuttlefish_mapping:parse({mapping, "ring_size", "riak_core.ring_creation_size",
                                  [
                                   {datatype, integer},
                                   {commented, 64},
                                   {doc, ["Default ring creation size.  Make sure it is a power of 2,",
                                          "e.g. 16, 32, 64, 128, 256, 512 etc"]}
                                  ]
                                 }),


    GeneratedConf = generate_element(TestSchemaElement),

    ?assertEqual(7, length(GeneratedConf)),
    ?assertEqual(
       "## Default ring creation size.  Make sure it is a power of 2,",
       lists:nth(1, GeneratedConf)
      ),
    ?assertEqual(
       "## e.g. 16, 32, 64, 128, 256, 512 etc",
       lists:nth(2, GeneratedConf)
      ),
    ?assertEqual(
       "## ",
       lists:nth(3, GeneratedConf)
      ),
    ?assertEqual(
       "## Acceptable values:",
       lists:nth(4, GeneratedConf)
      ),
    ?assertEqual(
       "##   - an integer",
       lists:nth(5, GeneratedConf)
      ),
    ?assertEqual(
       "## ring_size = 64",
       lists:nth(6, GeneratedConf)
      ),
    ok.

generate_conf_default_test() ->
    TestMappings = [{mapping, "default.absent", "undefined",
                     [{datatype, integer},
                      {new_conf_value, 42}]},
                     {mapping, "default.present", "undefined",
                      [{datatype, integer},
                       {default, -1},
                       {new_conf_value, 9001}]}],

    TestSchema = lists:map(fun cuttlefish_mapping:parse/1, TestMappings),
    GeneratedConf = generate(TestSchema),

    FindLine = fun(Key) ->
        hd([L || L <- GeneratedConf, string:prefix(L, Key) =/= nomatch])
    end,
    ?assertEqual("default.absent = 42",  FindLine("default.absent")),
    ?assertEqual("default.present = 9001", FindLine("default.present")),
    ok.

generate_dollar_test() ->
    TestSchemaElement =
        cuttlefish_mapping:parse({ mapping, "listener.http.$name", "riak_core.http", [
                                                                                      {datatype, ip},
                                                                                      {default, "127.0.0.1:8098"},
                                                                                      {mapping, "riak_core.http"},
                                                                                      {include_default,"internal"}
                                                                                     ]}),
    _GeneratedConf = generate_element(TestSchemaElement),

    ok.

generate_comments_test() ->
    SchemaElement = cuttlefish_mapping:parse({ mapping, "dont.care", "undefined", [
                                                                                   {doc, ["Hi!", "Bye!"]}
                                                                                  ]}),
    Comments = generate_comments(SchemaElement),
    ?assertEqual(["## Hi!", "## Bye!", "## ", "## Acceptable values:", "##   - text"], Comments).

utf8_bom_test() ->
    Conf = normalize_newlines(file("test/utf8_bom.conf")),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["foo"],"bar"}], Conf).

utf16_be_bom_test() ->
    Conf = normalize_newlines(file("test/utf16_be_bom.conf")),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["foo"],"bar"}], Conf).

utf16_le_bom_test() ->
    Conf = normalize_newlines(file("test/utf16_le_bom.conf")),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["foo"],"bar"}], Conf).

utf32_be_bom_test() ->
    Conf = normalize_newlines(file("test/utf32_be_bom.conf")),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["foo"],"bar"}], Conf).

utf32_le_bom_test() ->
    Conf = normalize_newlines(file("test/utf32_le_bom.conf")),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["foo"],"bar"}], Conf).

normalize_newlines(Conf) ->
    normalize_newlines(Conf, {os_type, os:type()}).

normalize_newlines(Conf, {os_type, {win32, _}}) ->
    {ok, Regex} = re:compile("\r\n", [unicode]),
    F = fun (V) ->
            re:replace(V, Regex, "\n", [global, {return, list}])
        end,
    normalize_newlines(Conf, F, []);
normalize_newlines(Conf, {os_type, _}) ->
    normalize_newlines(Conf, fun (V) -> V end, []).

normalize_newlines([], _F, Acc) ->
    Acc;
normalize_newlines({errorlist, _}=Errorlist, _F, _Acc) ->
    throw(Errorlist);
normalize_newlines({error, _}=Error, _F, _Acc) ->
    throw(Error);
normalize_newlines([{KeyList, Value}|Rest], F, Acc0) ->
    Acc1 = [{KeyList, F(Value)} | Acc0],
    normalize_newlines(Rest, F, Acc1).

multiline_test() ->
    Conf = normalize_newlines(file("test/multiline.conf")),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["a"],"    l0\n    l1\n    l2"}], Conf).

multiline_empty_test() ->
    Conf = file("test/multiline_empty.conf"),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["a"],[]}], Conf).

multiline_same_line_test() ->
    Conf = file("test/multiline_same_line.conf"),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["a"],"value"}], Conf).

multiline_special_chars_test() ->
    Conf = normalize_newlines(file("test/multiline_special_chars.conf")),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["a"], "key = value\n# comment\n'quoted' = \"double\""}], Conf).

multiline_unclosed_test() ->
    Conf = file("test/multiline_unclosed.conf"),
    ?assertMatch({errorlist, [{error, {conf_syntax, {"test/multiline_unclosed.conf", _}}}]}, Conf).

multiline_embedded_delimiter_test() ->
    Conf = file("test/multiline_embedded_delimiter.conf"),
    ?assertMatch({errorlist, [{error, {conf_syntax, {"test/multiline_embedded_delimiter.conf", _}}}]}, Conf).

multiline_with_trailing_comment_test() ->
    Conf = normalize_newlines(file("test/multiline_with_trailing_comment.conf")),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["a"],"value"}], Conf).

multiline_multiple_test() ->
    Conf = normalize_newlines(file("test/multiline_multiple.conf")),
    ?assertEqual(2, length(Conf)),
    ?assertMatch([{["b"],"l3\nl4\nl5"},
                  {["a"],"    l0\n    l1\n    l2"}], Conf).

multiline_mixed_test() ->
    Conf = normalize_newlines(file("test/multiline_mixed.conf")),
    ?assertEqual(3, length(Conf)),
    ?assertMatch([{["another"],"simple"},
                  {["regular"],"value"},
                  {["a"],"    l0\nl1\n    l2"}], Conf).

multiline_whitespace_only_test() ->
    Conf = normalize_newlines(file("test/multiline_whitespace_only.conf")),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["a"],"  \t\n\r  "}], Conf).

multiline_with_include_test() ->
    Conf = normalize_newlines(file("test/multiline_with_include.conf")),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["a"], "include other.conf\ninclude *.conf"}], Conf).

multiline_eof_test() ->
    Conf = file("test/multiline_eof.conf"),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["a"], "value"}], Conf).

multiline_unicode_test() ->
    Conf = normalize_newlines(file("test/multiline_unicode.conf")),
    ?assertEqual(1, length(Conf)),
    ?assertMatch([{["greeting"], "Hello 世界! 🌍\nПривіт, світ!\nПривет, мир!\nمرحبا بالعالم\nこんにちは世界"}], Conf).

duplicates_test() ->
    Conf = file("test/multi1.conf"),
    ?assertEqual(2, length(Conf)),
    ?assertEqual("3", proplists:get_value(["a","b","c"], Conf)),
    ?assertEqual("1", proplists:get_value(["a","b","d"], Conf)),
    ok.

duplicates_multi_test() ->
    Conf = files(["test/multi1.conf", "test/multi2.conf"]),
    ?assertEqual(2, length(Conf)),
    ?assertEqual("4", proplists:get_value(["a","b","c"], Conf)),
    ?assertEqual("1", proplists:get_value(["a","b","d"], Conf)),
    ok.

files_one_nonent_test() ->
    Conf = files(["test/multi1.conf", "test/nonent.conf"]),
    ?assertMatch({errorlist,[{error, {file_open, {"test/nonent.conf", _}}}]}, Conf),
    ok.

files_incomplete_parse_test() ->
    Conf = file("test/incomplete.conf"),
    ?assertEqual({errorlist, [{error, {conf_syntax, {"test/incomplete.conf", {3, 1}}}}]}, Conf),
    ok.

generate_element_level_advanced_test() ->
    _ = cuttlefish_test_logging:set_up(),
    _ = cuttlefish_test_logging:bounce(warning),
    assert_no_output({level, advanced}),
    [Log] = cuttlefish_test_logging:get_logs(),
    ?assertMatch({match, _}, re:run(Log, "{level, advanced} has been deprecated. Use 'hidden' or '{hidden, true}'")),
    ok.

generate_element_level_intermediate_test() ->
    _ = cuttlefish_test_logging:set_up(),
    _ = cuttlefish_test_logging:bounce(warning),
    assert_no_output({level, intermediate}),
    [Log] = cuttlefish_test_logging:get_logs(),
    ?assertMatch({match, _}, re:run(Log, "{level, intermediate} has been deprecated. Use 'hidden' or '{hidden, true}'")),
    ok.

generate_element_hidden_test() ->
    _ = cuttlefish_test_logging:set_up(),
    _ = cuttlefish_test_logging:bounce(warning),
    assert_no_output(hidden),
    assert_no_output({hidden, true}),
    ?assertEqual([], cuttlefish_test_logging:get_logs()),
    ok.

included_file_test() ->
    Conf = file("test/include_file.conf"),
    ?assertEqual(lists:sort([
            {["ring_size"],"32"},
            {["anti_entropy"],"debug"},
            {["log","error","file"],"/var/log/error.log"},
            {["log","console","file"],"/var/log/console.log"},
            {["log","syslog"],"on"},
            {["listener","http","internal"],"127.0.0.1:8098"},
            {["listener","http","external"],"10.0.0.1:80"}
        ]), lists:sort(Conf)),
    ok.

included_dir_test() ->
    Conf = file("test/include_dir.conf"),
    ?assertEqual(lists:sort([
            {["anti_entropy"],"debug"},
            {["log","error","file"],"/var/log/error.log"},
            {["log","console","file"],"/var/log/console.log"},
            {["ring_size"],"5"},
            {["rogue","option"],"42"},
            {["listener","http","internal"],"127.0.0.1:8098"},
            {["listener","http","external"],"10.0.0.1:80"},
            {["log","syslog"],"off"}
        ]), lists:sort(Conf)),
    ok.

included_value_test() ->
    Conf = file("test/included_value.conf"),
    ?assertEqual(lists:sort([
             {["value1"], "42"},
             {["value2"], "43"},
             {["value3"], "42"},
             {["value4"], "multi\nline\nvalue"},
             {["value5"], "12.34"},
             {["value6"], "$v1 [$v2] $v3 $v4"}
        ]), lists:sort(Conf)),
    ok.

assert_no_output(Setting) ->
    Mapping = cuttlefish_mapping:parse(
                {mapping,
                 "a", "b", [
                   {doc, ["blah"]},
                   Setting
               ]}),

    ?assertEqual([], generate_element(Mapping)).

generate_does_not_emit_aliases_test() ->
    Mapping = cuttlefish_mapping:parse({mapping, "new.key", "app.setting", [
        {default, "val"},
        {aliases, ["old.key"]}
    ]}),
    Generated = generate([Mapping]),
    FlatLines = [lists:flatten(L) || L <- Generated],
    ?assert(lists:any(fun(L) -> string:find(L, "new.key") =/= nomatch end, FlatLines)),
    ?assertNot(lists:any(fun(L) -> string:find(L, "old.key") =/= nomatch end, FlatLines)).

-endif.
