% Licensed 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(fabric_util).

-export([
    submit_jobs/3, submit_jobs/4,
    cleanup/1,
    recv/4,
    get_db/1, get_db/2,
    error_info/1,
    update_counter/3,
    remove_ancestors/2,
    create_monitors/1,
    kv/2,
    remove_down_workers/2, remove_down_workers/3,
    doc_id_and_rev/1
]).
-export([request_timeout/0, attachments_timeout/0, all_docs_timeout/0, view_timeout/1, timeout/2]).
-export([abs_request_timeout/0]).
-export([log_timeout/2, remove_done_workers/2]).
-export([is_users_db/1, is_replicator_db/1]).
-export([open_cluster_db/1, open_cluster_db/2]).
-export([is_partitioned/1]).
-export([validate_all_docs_args/2, validate_args/3]).
-export([upgrade_mrargs/1]).
-export([worker_ranges/1]).
-export([get_uuid_prefix_len/0]).
-export([isolate/1, isolate/2]).
-export([get_design_doc_records/1]).
-export([w_from_opts/2, r_from_opts/2]).

-compile({inline, [{doc_id_and_rev, 1}]}).

-include_lib("mem3/include/mem3.hrl").
-include_lib("couch/include/couch_db.hrl").
-include_lib("couch_mrview/include/couch_mrview.hrl").

remove_down_workers(Workers, BadNode) ->
    remove_down_workers(Workers, BadNode, []).

remove_down_workers(Workers, BadNode, RingOpts) ->
    Filter = fun(#shard{node = Node}, _) -> Node =/= BadNode end,
    NewWorkers = fabric_dict:filter(Filter, Workers),
    case fabric_ring:is_progress_possible(NewWorkers, RingOpts) of
        true ->
            {ok, NewWorkers};
        false ->
            error
    end.

submit_jobs(Shards, EndPoint, ExtraArgs) ->
    submit_jobs(Shards, fabric_rpc, EndPoint, ExtraArgs).

submit_jobs(Shards, Module, EndPoint, ExtraArgs) ->
    lists:map(
        fun(#shard{node = Node, name = ShardName} = Shard) ->
            Ref = rexi:cast(Node, {Module, EndPoint, [ShardName | ExtraArgs]}),
            Shard#shard{ref = Ref}
        end,
        Shards
    ).

cleanup(Workers) ->
    rexi:kill_all([{Node, Ref} || #shard{node = Node, ref = Ref} <- Workers]).

recv(Workers, Keypos, Fun, Acc0) ->
    rexi_utils:recv(Workers, Keypos, Fun, Acc0, request_timeout(), infinity).

request_timeout() ->
    timeout("request", "60000").

all_docs_timeout() ->
    timeout("all_docs", "10000").

attachments_timeout() ->
    timeout("attachments", "600000").

view_timeout(Args) ->
    PartitionQuery = couch_mrview_util:get_extra(Args, partition, false),
    case PartitionQuery of
        false -> timeout("view", "infinity");
        _ -> timeout("partition_view", "infinity")
    end.

timeout(Type, Default) ->
    case config:get("fabric", Type ++ "_timeout", Default) of
        "infinity" -> infinity;
        N -> list_to_integer(N)
    end.

log_timeout(Workers, EndPoint) ->
    CounterKey = [fabric, worker, timeouts],
    couch_stats:increment_counter(CounterKey),
    lists:map(
        fun(#shard{node = Dest, name = Name}) ->
            Fmt = "fabric_worker_timeout ~s,~p,~p",
            couch_log:error(Fmt, [EndPoint, Dest, Name])
        end,
        Workers
    ).

% Return {abs, MonotonicMSec}. This is a format used by erpc to
% provide an absolute time limit for a collection or requests
% See https://www.erlang.org/doc/apps/kernel/erpc.html#t:timeout_time/0
%
abs_request_timeout() ->
    Timeout = fabric_util:request_timeout(),
    NowMSec = erlang:monotonic_time(millisecond),
    {abs, NowMSec + Timeout}.

remove_done_workers(Workers, WaitingIndicator) ->
    [W || {W, WI} <- fabric_dict:to_list(Workers), WI == WaitingIndicator].

get_db(DbName) ->
    get_db(DbName, []).

get_db(DbName, Options) ->
    {Local, SameZone, DifferentZone} = mem3:group_by_proximity(mem3:shards(DbName)),
    % Prefer shards on the same node over other nodes, prefer shards in the same zone over
    % over zones and sort each remote list by name so that we don't repeatedly try the same node.
    Shards =
        Local ++ lists:keysort(#shard.name, SameZone) ++ lists:keysort(#shard.name, DifferentZone),
    % suppress shards from down nodes
    Nodes = [node() | nodes()],
    Live = [S || #shard{node = N} = S <- Shards, lists:member(N, Nodes)],
    % Only accept factors > 1, otherwise our math breaks further down
    Factor = max(2, config:get_integer("fabric", "shard_timeout_factor", 2)),
    MinTimeout = config:get_integer("fabric", "shard_timeout_min_msec", 100),
    MaxTimeout = request_timeout(),
    Timeout = get_db_timeout(length(Live), Factor, MinTimeout, MaxTimeout),
    get_shard(Live, Options, Timeout, Factor).

get_shard([], _Opts, _Timeout, _Factor) ->
    error({internal_server_error, "No DB shards could be opened."});
get_shard([#shard{node = Node, name = Name} | Rest], Opts, Timeout, Factor) ->
    Mon = rexi_monitor:start([rexi_utils:server_pid(Node)]),
    MFA = {fabric_rpc, open_shard, [Name, [{timeout, Timeout} | Opts]]},
    Ref = rexi:cast(Node, self(), MFA, [sync]),
    try
        receive
            {Ref, {ok, Db}} ->
                {ok, Db};
            {Ref, {'rexi_EXIT', {{unauthorized, _} = Error, _}}} ->
                throw(Error);
            {Ref, {'rexi_EXIT', {{forbidden, _} = Error, _}}} ->
                throw(Error);
            {Ref, Reason} ->
                couch_log:debug("Failed to open shard ~p because: ~p", [Name, Reason]),
                get_shard(Rest, Opts, Timeout, Factor)
        after Timeout ->
            couch_log:debug("Failed to open shard ~p after: ~p", [Name, Timeout]),
            get_shard(Rest, Opts, Factor * Timeout, Factor)
        end
    after
        rexi_monitor:stop(Mon)
    end.

get_db_timeout(N, Factor, MinTimeout, infinity) ->
    % MaxTimeout may be infinity so we just use the largest Erlang small int to
    % avoid blowing up the arithmetic
    get_db_timeout(N, Factor, MinTimeout, 1 bsl 59);
get_db_timeout(N, _Factor, MinTimeout, _MaxTimeout) when N > 64 ->
    % Guard against max:pow/2 blowing from a large exponent
    MinTimeout;
get_db_timeout(N, Factor, MinTimeout, MaxTimeout) ->
    %
    % The progression of timeouts forms a geometric series:
    %
    %     MaxTimeout = T + T*F + T*F^2 + T*F^3 ...
    %
    % Where T is the initial timeout and F is the factor. The formula for
    % the sum is:
    %
    %     Sum[T * F^I, I <- 0..N] = T * (1 - F^(N + 1)) / (1 - F)
    %
    % Then, for a given sum and factor we can calculate the initial timeout T:
    %
    %     T = Sum / ((1 - F^(N+1)) / (1 - F))
    %
    Timeout = MaxTimeout / ((1 - math:pow(Factor, N + 1)) / (1 - Factor)),
    % Apply a minimum timeout value
    max(MinTimeout, trunc(Timeout)).

error_info({{timeout, _} = Error, _Stack}) ->
    Error;
error_info({{Error, Reason}, Stack}) ->
    {Error, Reason, Stack};
error_info({Error, Stack}) ->
    {Error, nil, Stack}.

update_counter(Item, Incr, D) ->
    UpdateFun = fun({Old, Count}) -> {Old, Count + Incr} end,
    orddict:update(make_key(Item), UpdateFun, {Item, Incr}, D).

make_key({ok, L}) when is_list(L) ->
    make_key(L);
make_key([]) ->
    [];
make_key([{ok, #doc{revs = {Pos, [RevId | _]}}} | Rest]) ->
    [{ok, {Pos, RevId}} | make_key(Rest)];
make_key([{{not_found, missing}, Rev} | Rest]) ->
    [{not_found, Rev} | make_key(Rest)];
make_key({ok, #doc{id = Id, revs = Revs}}) ->
    {Id, Revs};
make_key(Else) ->
    Else.

% this presumes the incoming list is sorted, i.e. shorter revlists come first
remove_ancestors([], Acc) ->
    lists:reverse(Acc);
remove_ancestors([{_, {{not_found, _}, Count}} = Head | Tail], Acc) ->
    % any document is a descendant
    case
        lists:filter(
            fun
                ({_, {{ok, #doc{}}, _}}) -> true;
                (_) -> false
            end,
            Tail
        )
    of
        [{_, {{ok, #doc{}} = Descendant, _}} | _] ->
            remove_ancestors(update_counter(Descendant, Count, Tail), Acc);
        [] ->
            remove_ancestors(Tail, [Head | Acc])
    end;
remove_ancestors([{_, {{ok, #doc{revs = {Pos, Revs}}}, Count}} = Head | Tail], Acc) ->
    Descendants = lists:dropwhile(
        fun({_, {{ok, #doc{revs = {Pos2, Revs2}}}, _}}) ->
            case lists:nthtail(min(Pos2 - Pos, length(Revs2)), Revs2) of
                [] ->
                    % impossible to tell if Revs2 is a descendant - assume no
                    true;
                History ->
                    % if Revs2 is a descendant, History is a prefix of Revs
                    not lists:prefix(History, Revs)
            end
        end,
        Tail
    ),
    case Descendants of
        [] ->
            remove_ancestors(Tail, [Head | Acc]);
        [{Descendant, _} | _] ->
            remove_ancestors(update_counter(Descendant, Count, Tail), Acc)
    end;
remove_ancestors([Error | Tail], Acc) ->
    remove_ancestors(Tail, [Error | Acc]).

create_monitors(Shards) ->
    MonRefs = lists:usort([rexi_utils:server_pid(N) || #shard{node = N} <- Shards]),
    rexi_monitor:start(MonRefs).

is_replicator_db(DbName) ->
    path_ends_with(DbName, <<"_replicator">>).

is_users_db(DbName) ->
    ConfigName = list_to_binary(
        config:get(
            "chttpd_auth", "authentication_db", "_users"
        )
    ),
    DbName == ConfigName orelse path_ends_with(DbName, <<"_users">>).

path_ends_with(Path, Suffix) ->
    Suffix =:= couch_db:dbname_suffix(Path).

open_cluster_db(#shard{dbname = DbName}) ->
    open_cluster_db(DbName);
open_cluster_db(DbName) when is_binary(DbName) ->
    Props = mem3:props(DbName),
    {ok, Db} = couch_db:clustered_db(DbName, [{props, Props}]),
    Db.

open_cluster_db(DbName, Opts) ->
    % as admin
    {SecProps} = fabric:get_security(DbName),
    UserCtx = couch_util:get_value(user_ctx, Opts, #user_ctx{}),
    {ok, Db} = couch_db:clustered_db(DbName, UserCtx, SecProps),
    Db.

%% test function
kv(Item, Count) ->
    {make_key(Item), {Item, Count}}.

doc_id_and_rev(#doc{id = DocId, revs = {RevNum, [RevHash | _]}}) ->
    {DocId, {RevNum, RevHash}}.

is_partitioned(DbName) when is_binary(DbName) ->
    is_partitioned(open_cluster_db(DbName));
is_partitioned(Db) ->
    couch_db:is_partitioned(Db).

validate_all_docs_args(DbName, Args) when is_list(DbName) ->
    validate_all_docs_args(list_to_binary(DbName), Args);
validate_all_docs_args(DbName, Args) when is_binary(DbName) ->
    Db = open_cluster_db(DbName),
    validate_all_docs_args(Db, Args);
validate_all_docs_args(Db, Args) ->
    true = couch_db:is_clustered(Db),
    couch_mrview_util:validate_all_docs_args(Db, Args).

validate_args(DbName, DDoc, Args) when is_binary(DbName) ->
    Db = open_cluster_db(DbName),
    validate_args(Db, DDoc, Args);
validate_args(Db, DDoc, Args) ->
    true = couch_db:is_clustered(Db),
    couch_mrview_util:validate_args(Db, DDoc, Args).

upgrade_mrargs(#mrargs{} = Args) ->
    Args;
upgrade_mrargs(
    {mrargs, ViewType, Reduce, PreflightFun, StartKey, StartKeyDocId, EndKey, EndKeyDocId, Keys,
        Direction, Limit, Skip, GroupLevel, Group, Stale, MultiGet, InclusiveEnd, IncludeDocs,
        DocOptions, UpdateSeq, Conflicts, Callback, Sorted, Extra}
) ->
    {Stable, Update} =
        case Stale of
            ok -> {true, false};
            update_after -> {true, lazy};
            _ -> {false, true}
        end,
    #mrargs{
        view_type = ViewType,
        reduce = Reduce,
        preflight_fun = PreflightFun,
        start_key = StartKey,
        start_key_docid = StartKeyDocId,
        end_key = EndKey,
        end_key_docid = EndKeyDocId,
        keys = Keys,
        direction = Direction,
        limit = Limit,
        skip = Skip,
        group_level = GroupLevel,
        group = Group,
        stable = Stable,
        update = Update,
        multi_get = MultiGet,
        inclusive_end = InclusiveEnd,
        include_docs = IncludeDocs,
        doc_options = DocOptions,
        update_seq = UpdateSeq,
        conflicts = Conflicts,
        callback = Callback,
        sorted = Sorted,
        extra = Extra
    }.

worker_ranges(Workers) ->
    Ranges = fabric_dict:fold(
        fun(#shard{range = [X, Y]}, _, Acc) ->
            [{X, Y} | Acc]
        end,
        [],
        Workers
    ),
    lists:usort(Ranges).

get_uuid_prefix_len() ->
    config:get_integer("fabric", "uuid_prefix_len", 7).

% Get design #doc{} records. Run in an isolated process. This is often used
% when computing signatures of various indexes
%
get_design_doc_records(DbName) ->
    fabric_util:isolate(fun() ->
        case fabric:design_docs(DbName) of
            {ok, DDocs} when is_list(DDocs) ->
                Fun = fun({[_ | _]} = Doc) -> couch_doc:from_json_obj(Doc) end,
                {ok, lists:map(Fun, DDocs)};
            Else ->
                Else
        end
    end).

w_from_opts(Db, Options) ->
    quorum_from_opts(Db, couch_util:get_value(w, Options)).

r_from_opts(Db, Options) ->
    quorum_from_opts(Db, couch_util:get_value(r, Options)).

quorum_from_opts(Db, Val) ->
    try
        if
            is_integer(Val) ->
                Val;
            is_list(Val) ->
                % Compatibility clause. Keep as long as chttpd parses r and w
                % request parameters as lists (strings).
                list_to_integer(Val);
            true ->
                mem3:quorum(Db)
        end
    catch
        _:_ ->
            mem3:quorum(Db)
    end.

% If we issue multiple fabric calls from the same process we have to isolate
% them so in case of error they don't pollute the processes dictionary or the
% mailbox

isolate(Fun) ->
    isolate(Fun, infinity).

isolate(Fun, Timeout) ->
    {Pid, Ref} = spawn_monitor(fun() -> exit(do_isolate(Fun)) end),
    receive
        {'DOWN', Ref, _, _, {'$isolres', Res}} ->
            Res;
        {'DOWN', Ref, _, _, {'$isolerr', Tag, Reason, Stack}} ->
            erlang:raise(Tag, Reason, Stack)
    after Timeout ->
        demonitor(Ref, [flush]),
        exit(Pid, kill),
        error(timeout)
    end.

do_isolate(Fun) ->
    try
        {'$isolres', Fun()}
    catch
        Tag:Reason:Stack ->
            {'$isolerr', Tag, Reason, Stack}
    end.

-ifdef(TEST).
-include_lib("couch/include/couch_eunit.hrl").

%% verify only id and rev are used in key.
update_counter_test() ->
    Reply =
        {ok, #doc{
            id = <<"id">>,
            revs = <<"rev">>,
            body = <<"body">>,
            atts = <<"atts">>
        }},
    ?assertEqual(
        [{{<<"id">>, <<"rev">>}, {Reply, 1}}],
        update_counter(Reply, 1, [])
    ).

remove_ancestors_test() ->
    Foo1 = {ok, #doc{revs = {1, [<<"foo">>]}}},
    Foo2 = {ok, #doc{revs = {2, [<<"foo2">>, <<"foo">>]}}},
    Bar1 = {ok, #doc{revs = {1, [<<"bar">>]}}},
    Bar2 = {not_found, {1, <<"bar">>}},
    ?assertEqual(
        [kv(Bar1, 1), kv(Foo1, 1)],
        remove_ancestors([kv(Bar1, 1), kv(Foo1, 1)], [])
    ),
    ?assertEqual(
        [kv(Bar1, 1), kv(Foo2, 2)],
        remove_ancestors([kv(Bar1, 1), kv(Foo1, 1), kv(Foo2, 1)], [])
    ),
    ?assertEqual(
        [kv(Bar1, 2)],
        remove_ancestors([kv(Bar2, 1), kv(Bar1, 1)], [])
    ).

get_db_timeout_test() ->
    % Q=1, N=1
    ?assertEqual(20000, get_db_timeout(1, 2, 100, 60000)),

    % Q=2, N=1
    ?assertEqual(8571, get_db_timeout(2, 2, 100, 60000)),

    % Q=2, N=3 (default)
    ?assertEqual(472, get_db_timeout(2 * 3, 2, 100, 60000)),

    % Q=3, N=3
    ?assertEqual(100, get_db_timeout(3 * 3, 2, 100, 60000)),

    % Q=4, N=1
    ?assertEqual(1935, get_db_timeout(4, 2, 100, 60000)),

    % Q=8, N=1
    ?assertEqual(117, get_db_timeout(8, 2, 100, 60000)),

    % Q=8, N=3 (default in 2.x)
    ?assertEqual(100, get_db_timeout(8 * 3, 2, 100, 60000)),

    % Q=256, N=3
    ?assertEqual(100, get_db_timeout(256 * 3, 2, 100, 60000)),

    % Q=9999 N=3
    ?assertEqual(100, get_db_timeout(9999 * 3, 2, 100, 60000)),

    % Large factor = 100
    ?assertEqual(100, get_db_timeout(2 * 3, 100, 100, 60000)),

    % Small total request timeout = 1 sec
    ?assertEqual(100, get_db_timeout(2 * 3, 2, 100, 1000)),

    % Large total request timeout
    ?assertEqual(28346, get_db_timeout(2 * 3, 2, 100, 3600000)),

    % No shards at all
    ?assertEqual(60000, get_db_timeout(0, 2, 100, 60000)),

    % request_timeout was set to infinity, with enough shards it still gets to
    % 100 min timeout at the start from the exponential logic
    ?assertEqual(100, get_db_timeout(64, 2, 100, infinity)).

rw_opts_test_() ->
    {
        foreach,
        fun() -> meck:new(mem3, [passthrough]) end,
        fun(_) -> meck:unload() end,
        [
            ?TDEF_FE(t_w_opts_get),
            ?TDEF_FE(t_r_opts_get)
        ]
    }.

t_w_opts_get(_) ->
    meck:expect(mem3, quorum, 1, 3),
    ?assertEqual(5, w_from_opts(any_db, [{w, 5}])),
    ?assertEqual(5, w_from_opts(any_db, [{w, "5"}])),
    ?assertEqual(3, w_from_opts(any_db, [{w, some_other_type}])),
    ?assertEqual(3, w_from_opts(any_db, [{w, "five"}])),
    ?assertEqual(3, w_from_opts(any_db, [])).

t_r_opts_get(_) ->
    meck:expect(mem3, quorum, 1, 3),
    ?assertEqual(5, r_from_opts(any_db, [{other_opt, 42}, {r, 5}])),
    ?assertEqual(5, r_from_opts(any_db, [{r, "5"}, {something_else, "xyz"}])),
    ?assertEqual(3, r_from_opts(any_db, [{r, some_other_type}])),
    ?assertEqual(3, r_from_opts(any_db, [{r, "five"}])),
    ?assertEqual(3, r_from_opts(any_db, [])).

-endif.
