%%%--------------------------------------------------------------------
%%% @author Wojciech Geisler
%%% @copyright (C) 2018 ACK CYFRONET AGH
%%% This software is released under the MIT license
%%% cited in 'LICENSE.txt'.
%%% @end
%%%--------------------------------------------------------------------
%%% @doc Utilities for fetching clusters information from Onezone.
%%% @end
%%%--------------------------------------------------------------------
-module(clusters).
-author("Wojciech Geisler").

-include("names.hrl").
-include("http/rest.hrl").
-include("modules/errors.hrl").
-include_lib("ctool/include/api_errors.hrl").
-include_lib("ctool/include/logging.hrl").
-include_lib("ctool/include/onedata.hrl").

-type id() :: binary().

-export_type([id/0]).

%% API
-export([get_id/0]).
-export([get_user_privileges/1]).
-export([get_current_cluster/0, get_details/2, list_user_clusters/1,
    get_members_summary/1]).
-export([fetch_remote_provider_info/2]).
-export([create_user_invite_token/0]).

-define(PRIVILEGES_CACHE_KEY(OnezoneUserId), {privileges, OnezoneUserId}).
-define(PRIVILEGES_CACHE_TTL, onepanel_env:get(onezone_auth_cache_ttl, ?APP_NAME, 0)).

%%--------------------------------------------------------------------
%% @doc Returns Id of this cluster.
%% @end
%%--------------------------------------------------------------------
-spec get_id() -> id().
get_id() ->
    case onepanel_env:get_cluster_type() of
        onezone ->
            ?ONEZONE_CLUSTER_ID;
        oneprovider ->
            <<_Id/binary>> = service_oneprovider:get_id()
    end.


%%--------------------------------------------------------------------
%% @doc Returns details of current cluster.
%% Uses the root authorization, since all users with onepanel access
%% should have access to the cluster details.
%% @end
%%--------------------------------------------------------------------
-spec get_current_cluster() ->
    #{atom() := term()} | #error{}.
get_current_cluster() ->
    try
        Auth = onezone_client:root_auth(),
        {ok, Details} = get_details(Auth, get_id()),
        store_in_cache(cluster, Details),
        Details
    catch _Type:Error ->
        try_cached(cluster, ?make_stacktrace(Error))
    end.


%%--------------------------------------------------------------------
%% @doc Returns summary with counts of users and groups belonging
%% to the current cluster.
%% @end
%%--------------------------------------------------------------------
-spec get_members_summary(rest_handler:zone_auth()) ->
    #{atom() := non_neg_integer()} | no_return().
get_members_summary(Auth) ->
    Users = get_members_count(Auth, users, direct),
    EffUsers = get_members_count(Auth, users, effective),
    Groups = get_members_count(Auth, groups, direct),
    EffGroups = get_members_count(Auth, groups, effective),
    #{
        usersCount => Users, groupsCount => Groups,
        effectiveUsersCount => EffUsers, effectiveGroupsCount => EffGroups
    }.


%%--------------------------------------------------------------------
%% @doc Returns user privileges in the current cluster by UserId.
%% Throws if connection to Onezone could not be established.
%% Retrieves the credentials using root client authorization
%% as the user might not have enough privileges to view his own privileges.
%% @end
%%--------------------------------------------------------------------
-spec get_user_privileges(OnezoneUserId :: binary()) ->
    {ok, [privileges:cluster_privilege()]} | #error{} | no_return().
get_user_privileges(OnezoneUserId) ->
    RootAuth = onezone_client:root_auth(),
    get_user_privileges(RootAuth, OnezoneUserId).


%%--------------------------------------------------------------------
%% @private
%% @doc Returns user privileges in the current cluster by UserId.
%% Uses specified authentication for the request.
%% @end
%%--------------------------------------------------------------------
-spec get_user_privileges(rest_handler:zone_auth(), OnezoneUserId :: binary()) ->
    {ok, [privileges:cluster_privilege()]} | #error{} | no_return().
get_user_privileges({rest, RestAuth}, OnezoneUserId) ->
    simple_cache:get(?PRIVILEGES_CACHE_KEY(OnezoneUserId), fun() ->
        case zone_rest(RestAuth, "/clusters/~s/effective_users/~s/privileges",
            [get_id(), OnezoneUserId]) of
            {ok, #{privileges := Privileges}} ->
                ListOfAtoms = onepanel_utils:convert(Privileges, {seq, atom}),
                {true, ListOfAtoms, ?PRIVILEGES_CACHE_TTL};
            #error{reason = {401, _, _}} ->
                ?make_error(?ERR_UNAUTHORIZED);
            #error{reason = {404, _, _}} ->
                ?make_error(?ERR_USER_NOT_IN_CLUSTER);
            #error{} = Error -> throw(Error)
        end
    end);

get_user_privileges({rpc, LogicClient}, OnezoneUserId) ->
    case zone_rpc(cluster_logic, get_eff_user_privileges,
        [LogicClient, get_id(), OnezoneUserId]) of
        #error{reason = ?ERR_NOT_FOUND} -> ?make_error(?ERR_USER_NOT_IN_CLUSTER);
        {ok, Privileges} -> {ok, Privileges}
    end.


%%--------------------------------------------------------------------
%% @doc Returns protected details of a cluster.
%% @end
%%--------------------------------------------------------------------
-spec get_details(Auth :: rest_handler:zone_auth(), ClusterId :: id()) ->
    {ok, #{atom() := term()}} | #error{}.
get_details({rpc, Auth}, ClusterId) ->
    case zone_rpc(cluster_logic, get_protected_data, [Auth, ClusterId]) of
        {ok, ClusterData} ->
            {ok, onepanel_maps:get_store_multiple([
                {<<"onepanelVersion">>, onepanelVersion},
                {<<"workerVersion">>, workerVersion},
                {<<"onepanelProxy">>, onepanelProxy},
                {<<"type">>, type}
            ], ClusterData, #{id => ClusterId, serviceId => ClusterId})};
        Error -> Error
    end;

get_details({rest, Auth}, ClusterId) ->
    case zone_rest(Auth, "/clusters/~s", [ClusterId]) of
        {ok, Map} ->
            Map2 = maps:without([clusterId], Map),
            {ok, Map2#{id => ClusterId, serviceId => ClusterId}};
        Error -> Error
    end.


%%--------------------------------------------------------------------
%% @doc Returns ids of clusters belonging to the authenticated user.
%% @end
%%--------------------------------------------------------------------
-spec list_user_clusters(rest_handler:zone_auth()) ->
    {ok, [id()]} | #error{}.
list_user_clusters({rpc, Auth}) ->
    zone_rpc(user_logic, get_clusters, [Auth]);

list_user_clusters({rest, Auth}) ->
    case zone_rest(Auth, "/user/effective_clusters/", []) of
        {ok, #{clusters := Ids}} -> {ok, Ids};
        #error{} = Error -> Error
    end.


%%--------------------------------------------------------------------
%% @doc Fetches information about a remote provider.
%% User must belong to its cluster.
%% @end
%%--------------------------------------------------------------------
-spec fetch_remote_provider_info(Auth :: rest_handler:zone_auth(), ProviderId :: binary()) ->
    #{binary() := term()}.
fetch_remote_provider_info({rpc, Client}, ProviderId) ->
    {ok, OzNode} = nodes:any(?SERVICE_OZW),
    case rpc:call(
        OzNode, provider_logic, get_protected_data, [Client, ProviderId]
    ) of
        {ok, ProviderData} -> format_provider_info(ProviderData);
        {error, not_found} -> ?throw_error(?ERR_NOT_FOUND)
    end;

fetch_remote_provider_info({rest, RestAuth}, ProviderId) ->
    URN = "/providers/" ++ binary_to_list(ProviderId),
    case oz_endpoint:request(RestAuth, URN, get) of
        {ok, 200, _, BodyJson} ->
            format_provider_info(json_utils:decode(BodyJson));
        {ok, 404, _, _} ->
            ?throw_error(?ERROR_NOT_FOUND)
    end.


%%--------------------------------------------------------------------
%% @doc
%% Obtains token which enables a Onezone user to join current cluster.
%% @end
%%--------------------------------------------------------------------
-spec create_user_invite_token() -> {ok, Token :: binary()} | #error{}.
create_user_invite_token() ->
    create_user_invite_token(onezone_client:root_auth()).


%%%===================================================================
%%% Internal functions
%%%===================================================================

%% @private
-spec zone_rpc(Module :: module(), Function :: atom(), Args :: [term()]) ->
    term() | #error{}.
zone_rpc(Module, Function, Args) ->
    {ok, OzNode} = nodes:any(?SERVICE_OZW),
    case rpc:call(OzNode, Module, Function, Args) of
        {badrpc, _} = Error -> ?make_error(Error);
        {error, Reason} -> ?make_error(Reason);
        Result -> Result
    end.


%% @private
-spec zone_rest(Auth :: oz_plugin:auth(),
    URNFormat :: string(), FormatArgs :: [term()]) ->
    {ok, #{atom() => term()}} | #error{}.
zone_rest(Auth, URNFormat, FormatArgs) ->
    zone_rest(get, Auth, URNFormat, FormatArgs).

%% @private
-spec zone_rest(Method :: http_client:method(), Auth :: oz_plugin:auth(),
    URNFormat :: string(), FormatArgs :: [term()]) ->
    {ok, #{atom() => term()}} | #error{}.
zone_rest(Method, Auth, URNFormat, FormatArgs) ->
    URN = str_utils:format(URNFormat, FormatArgs),
    case oz_endpoint:request(Auth, URN, Method) of
        {ok, 200, _, BodyJson} ->
            Parsed = onepanel_utils:convert(json_utils:decode(BodyJson), {keys, atom}),
            {ok, Parsed};
        {ok, Code, Error, Description} ->
            ?make_error({Code, Error, Description});
        {error, Reason} ->
            ?make_error(Reason)
    end.


%%--------------------------------------------------------------------
%% @private
%% @doc Stores given data in service's ctx to make them available
%% when the service nodes are offline.
%% @end
%%--------------------------------------------------------------------
-spec store_in_cache(Key :: term(), Value :: term()) -> ok.
store_in_cache(Key, Value) ->
    Service = onepanel_env:get_cluster_type(),
    service:update_ctx(Service, #{Key => Value}).


%%--------------------------------------------------------------------
%% @private
%% @doc Retrieves given key from service ctx.
%% Throws given Error on failure.
%% @end
%%--------------------------------------------------------------------
-spec try_cached(Key :: term(), ErrorResult :: term()) ->
    FoundValue :: term().
try_cached(Key, Error) ->
    Service = onepanel_env:get_cluster_type(),
    case service:get_ctx(Service) of
        #{Key := Value} -> Value;
        _ -> throw(Error)
    end.


%%--------------------------------------------------------------------
%% @private
%% @doc Returns number of given entities (users or groups)
%% belonging to the cluster - either directly or effectively.
%% @end
%%--------------------------------------------------------------------
-spec get_members_count(Auth :: rest_handler:zone_auth(),
    UsersOrGroups :: users | groups, DirectOrEffective :: direct | effective) ->
    non_neg_integer().
get_members_count({rest, Auth}, UsersOrGroups, DirectOrEffective) ->
    {Resource, ResponseKey} = case {UsersOrGroups, DirectOrEffective} of
        {users, direct} -> {"users", users};
        {users, effective} -> {"effective_users", users};
        {groups, direct} -> {"groups", groups};
        {groups, effective} -> {"effective_groups", groups}
    end,

    case zone_rest(Auth, "/clusters/~s/~s", [get_id(), Resource]) of
        {ok, #{ResponseKey := List}} -> length(List);
        Error -> ?throw_error(Error)
    end;

get_members_count({rpc, Auth}, UsersOrGroups, DirectOrEffective) ->
    Function = case {UsersOrGroups, DirectOrEffective} of
        {users, direct} -> get_users;
        {users, effective} -> get_eff_users;
        {groups, direct} -> get_groups;
        {groups, effective} -> get_eff_groups
    end,

    case zone_rpc(cluster_logic, Function, [Auth, get_id()]) of
        {ok, List} -> length(List);
        Error -> ?throw_error(Error)
    end.


%% @private
-spec create_user_invite_token(rest_handler:zone_auth()) ->
    {ok, Token :: binary()} | #error{}.
create_user_invite_token({rpc, Auth}) ->
    case zone_rpc(cluster_logic, create_user_invite_token,
        [Auth, get_id()]) of
        {ok, Macaroon} -> onedata_macaroons:serialize(Macaroon);
        Error -> Error
    end;

create_user_invite_token({rest, Auth}) ->
    case zone_rest(
        post, Auth, "/clusters/~s/users/token", [get_id()]
    ) of
        {ok, #{token := Token}} -> {ok, Token};
        Error -> Error
    end.


%% @private
-spec format_provider_info(OzResponse :: #{binary() => term()}) ->
    #{binary() => term()}.
format_provider_info(OzResponse) ->
    onepanel_maps:get_store_multiple([
        {<<"providerId">>, <<"id">>},
        {<<"name">>, <<"name">>},
        {<<"domain">>, <<"domain">>},
        {<<"longitude">>, <<"geoLongitude">>},
        {<<"latitude">>, <<"geoLatitude">>},
        {<<"cluster">>, <<"cluster">>},
        {<<"online">>, <<"online">>}
    ], OzResponse).
