%%%-------------------------------------------------------------------
%%% Author  : Evgeny Khramtsov <ekhramtsov@process-one.net>
%%% Created : 22 Oct 2016 by Evgeny Khramtsov <ekhramtsov@process-one.net>
%%%
%%%
%%% ejabberd, Copyright (C) 2002-2025   ProcessOne
%%%
%%% This program is free software; you can redistribute it and/or
%%% modify it under the terms of the GNU General Public License as
%%% published by the Free Software Foundation; either version 2 of the
%%% License, or (at your option) any later version.
%%%
%%% This program is distributed in the hope that it will be useful,
%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
%%% General Public License for more details.
%%%
%%% You should have received a copy of the GNU General Public License along
%%% with this program; if not, write to the Free Software Foundation, Inc.,
%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
%%%
%%%----------------------------------------------------------------------

-module(roster_tests).

%% API
-compile(export_all).
-import(suite, [send_recv/2, recv_iq/1, send/2, disconnect/1, del_roster/1,
		del_roster/2, make_iq_result/1, wait_for_slave/1,
		wait_for_master/1, recv_presence/1, self_presence/2,
		put_event/2, get_event/1, match_failure/2, get_roster/1]).
-include("suite.hrl").
-include("mod_roster.hrl").

-record(state, {subscription = none :: none | from | to | both,
		peer_available = false,
		pending_in = false :: boolean(),
		pending_out = false :: boolean()}).

%%%===================================================================
%%% API
%%%===================================================================
init(_TestCase, Config) ->
    Config.

stop(_TestCase, Config) ->
    Config.

%%%===================================================================
%%% Single user tests
%%%===================================================================
single_cases() ->
    {roster_single, [sequence],
     [single_test(feature_enabled),
      single_test(iq_set_many_items),
      single_test(iq_set_duplicated_groups),
      single_test(iq_get_item),
      single_test(iq_unexpected_element),
      single_test(iq_set_ask),
      single_test(set_item),
      single_test(version)]}.

feature_enabled(Config) ->
    ct:comment("Checking if roster versioning stream feature is set"),
    true = ?config(rosterver, Config),
    disconnect(Config).

set_item(Config) ->
    JID = jid:decode(<<"nurse@example.com">>),
    Item = #roster_item{jid = JID},
    {V1, Item} = set_items(Config, [Item]),
    {V1, [Item]} = get_items(Config),
    ItemWithGroups = Item#roster_item{groups = [<<"G1">>, <<"G2">>]},
    {V2, ItemWithGroups} = set_items(Config, [ItemWithGroups]),
    {V2, [ItemWithGroups]} = get_items(Config),
    {V3, Item} = set_items(Config, [Item]),
    {V3, [Item]} = get_items(Config),
    ItemWithName = Item#roster_item{name = <<"some name">>},
    {V4, ItemWithName} = set_items(Config, [ItemWithName]),
    {V4, [ItemWithName]} = get_items(Config),
    ItemRemoved = Item#roster_item{subscription = remove},
    {V5, ItemRemoved} = set_items(Config, [ItemRemoved]),
    {V5, []} = get_items(Config),
    del_roster(disconnect(Config), JID).

iq_set_many_items(Config) ->
    J1 = jid:decode(<<"nurse1@example.com">>),
    J2 = jid:decode(<<"nurse2@example.com">>),
    ct:comment("Trying to send roster-set with many <item/> elements"),
    Items = [#roster_item{jid = J1}, #roster_item{jid = J2}],
    #stanza_error{reason = 'bad-request'} = set_items(Config, Items),
    disconnect(Config).

iq_set_duplicated_groups(Config) ->
    JID = jid:decode(<<"nurse@example.com">>),
    G = p1_rand:get_string(),
    ct:comment("Trying to send roster-set with duplicated groups"),
    Item = #roster_item{jid = JID, groups = [G, G]},
    #stanza_error{reason = 'bad-request'} = set_items(Config, [Item]),
    disconnect(Config).

iq_set_ask(Config) ->
    JID = jid:decode(<<"nurse@example.com">>),
    ct:comment("Trying to send roster-set with 'ask' included"),
    Item = #roster_item{jid = JID, ask = subscribe},
    #stanza_error{reason = 'bad-request'} = set_items(Config, [Item]),
    disconnect(Config).

iq_get_item(Config) ->
    JID = jid:decode(<<"nurse@example.com">>),
    ct:comment("Trying to send roster-get with <item/> element"),
    #iq{type = error} = Err3 =
	send_recv(Config, #iq{type = get,
			      sub_els = [#roster_query{
					    items = [#roster_item{jid = JID}]}]}),
    #stanza_error{reason = 'bad-request'} = xmpp:get_error(Err3),
    disconnect(Config).

iq_unexpected_element(Config) ->
    JID = jid:decode(<<"nurse@example.com">>),
    ct:comment("Trying to send IQs with unexpected element"),
    lists:foreach(
      fun(Type) ->
	      #iq{type = error} = Err4 =
		  send_recv(Config, #iq{type = Type,
					sub_els = [#roster_item{jid = JID}]}),
	      #stanza_error{reason = 'service-unavailable'} = xmpp:get_error(Err4)
      end, [get, set]),
    disconnect(Config).

version(Config) ->
    JID = jid:decode(<<"nurse@example.com">>),
    ct:comment("Requesting roster"),
    {InitialVersion, _} = get_items(Config, <<"">>),
    ct:comment("Requesting roster with initial version"),
    {empty, []} = get_items(Config, InitialVersion),
    ct:comment("Adding JID to the roster"),
    {NewVersion, _} = set_items(Config, [#roster_item{jid = JID}]),
    ct:comment("Requesting roster with initial version"),
    {NewVersion, _} = get_items(Config, InitialVersion),
    ct:comment("Requesting roster with new version"),
    {empty, []} = get_items(Config, NewVersion),
    del_roster(disconnect(Config), JID).

%%%===================================================================
%%% Master-slave tests
%%%===================================================================
master_slave_cases() ->
    {roster_master_slave, [sequence],
     [master_slave_test(subscribe)]}.

subscribe_master(Config) ->
    Actions = actions(),
    process_subscriptions_master(Config, Actions),
    del_roster(disconnect(Config)).

subscribe_slave(Config) ->
    process_subscriptions_slave(Config),
    del_roster(disconnect(Config)).

process_subscriptions_master(Config, Actions) ->
    EnumeratedActions = lists:zip(lists:seq(1, length(Actions)), Actions),
    self_presence(Config, available),
    Peer = ?config(peer, Config),
    lists:foldl(
      fun({N, {Dir, Type}}, State) ->
	      if Dir == out -> put_event(Config, {N, in, Type});
		 Dir == in -> put_event(Config, {N, out, Type})
	      end,
	      Roster = get_roster(Config),
	      ct:pal("Performing ~s-~s (#~p) "
		     "in state:~n~s~nwith roster:~n~s",
		     [Dir, Type, N, pp(State), pp(Roster)]),
	      check_roster(Roster, Config, State),
	      wait_for_slave(Config),
	      Id = mk_id(N, Dir, Type),
	      NewState = transition(Id, Config, Dir, Type, State),
	      wait_for_slave(Config),
	      send_recv(Config, #iq{type = get, to = Peer, id = Id,
				    sub_els = [#ping{}]}),
	      check_roster_item(Config, NewState),
	      NewState
      end, #state{}, EnumeratedActions),
    put_event(Config, done),
    wait_for_slave(Config),
    Config.

process_subscriptions_slave(Config) ->
    self_presence(Config, available),
    process_subscriptions_slave(Config, get_event(Config), #state{}).

process_subscriptions_slave(Config, done, _State) ->
    wait_for_master(Config),
    Config;
process_subscriptions_slave(Config, {N, Dir, Type}, State) ->
    Roster = get_roster(Config),
    ct:pal("Performing ~s-~s (#~p) "
	   "in state:~n~s~nwith roster:~n~s",
	   [Dir, Type, N, pp(State), pp(Roster)]),
    check_roster(Roster, Config, State),
    wait_for_master(Config),
    NewState = transition(mk_id(N, Dir, Type), Config, Dir, Type, State),
    wait_for_master(Config),
    send(Config, xmpp:make_iq_result(recv_iq(Config))),
    check_roster_item(Config, NewState),
    process_subscriptions_slave(Config, get_event(Config), NewState).

%%%===================================================================
%%% Internal functions
%%%===================================================================
single_test(T) ->
    list_to_atom("roster_" ++ atom_to_list(T)).

master_slave_test(T) ->
    {list_to_atom("roster_" ++ atom_to_list(T)), [parallel],
     [list_to_atom("roster_" ++ atom_to_list(T) ++ "_master"),
      list_to_atom("roster_" ++ atom_to_list(T) ++ "_slave")]}.

get_items(Config) ->
    get_items(Config, <<"">>).

get_items(Config, Version) ->
    case send_recv(Config, #iq{type = get,
			       sub_els = [#roster_query{ver = Version}]}) of
	#iq{type = result,
	    sub_els = [#roster_query{ver = NewVersion, items = Items}]} ->
	    {NewVersion, normalize_items(Items)};
	#iq{type = result, sub_els = []} ->
	    {empty, []};
	#iq{type = error} = Err ->
	    xmpp:get_error(Err)
    end.

normalize_items(Items) ->
    Items2 =
        lists:map(
          fun(I) ->
                  I#roster_item{groups = lists:sort(I#roster_item.groups)}
          end, Items),
    lists:sort(Items2).

get_item(Config, JID) ->
    case get_items(Config) of
	{_Ver, Items} when is_list(Items) ->
	    lists:keyfind(JID, #roster_item.jid, Items);
	_ ->
	    false
    end.

set_items(Config, Items) ->
    case send_recv(Config, #iq{type = set,
			       sub_els = [#roster_query{items = Items}]}) of
	#iq{type = result, sub_els = []} ->
	    recv_push(Config);
	#iq{type = error} = Err ->
	    xmpp:get_error(Err)
    end.

recv_push(Config) ->
    ct:comment("Receiving roster push"),
    Push = #iq{type = set,
	       sub_els = [#roster_query{ver = Ver, items = [PushItem]}]}
	= recv_iq(Config),
    send(Config, make_iq_result(Push)),
    {Ver, PushItem}.

recv_push(Config, Subscription, Ask) ->
    PeerJID = ?config(peer, Config),
    PeerBareJID = jid:remove_resource(PeerJID),
    Match = #roster_item{jid = PeerBareJID,
			 subscription = Subscription,
			 ask = Ask,
			 groups = [],
			 name = <<"">>},
    ct:comment("Receiving roster push"),
    Push = #iq{type = set, sub_els = [#roster_query{items = [Item]}]} =
	recv_iq(Config),
    case Item of
	Match -> send(Config, make_iq_result(Push));
	_ -> match_failure(Item, Match)
    end.

recv_presence(Config, Type) ->
    PeerJID = ?config(peer, Config),
    case recv_presence(Config) of
	#presence{from = PeerJID, type = Type} -> ok;
	Pres -> match_failure(Pres, #presence{from = PeerJID, type = Type})
    end.

recv_subscription(Config, Type) ->
    PeerJID = ?config(peer, Config),
    PeerBareJID = jid:remove_resource(PeerJID),
    case recv_presence(Config) of
	#presence{from = PeerBareJID, type = Type} -> ok;
	Pres -> match_failure(Pres, #presence{from = PeerBareJID, type = Type})
    end.

pp(Term) ->
    io_lib_pretty:print(Term, fun pp/2).

pp(state, N) ->
    Fs = record_info(fields, state),
    try N = length(Fs), Fs
    catch _:_ -> no end;
pp(roster, N) ->
    Fs = record_info(fields, roster),
    try N = length(Fs), Fs
    catch _:_ -> no end;
pp(_, _) -> no.

mk_id(N, Dir, Type) ->
    list_to_binary([integer_to_list(N), $-, atom_to_list(Dir),
		    $-, atom_to_list(Type)]).

check_roster([], _Config, _State) ->
    ok;
check_roster([Roster], _Config, State) ->
    case {Roster#roster.subscription == State#state.subscription,
	  Roster#roster.ask, State#state.pending_in, State#state.pending_out} of
	{true, both, true, true} -> ok;
	{true, in, true, false} -> ok;
	{true, out, false, true} -> ok;
	{true, none, false, false} -> ok;
	_ ->
	    ct:fail({roster_mismatch, State, Roster})
    end.

check_roster_item(Config, State) ->
    Peer = jid:remove_resource(?config(peer, Config)),
    RosterItem = case get_item(Config, Peer) of
		     false -> #roster_item{};
		     Item -> Item
		 end,
    case {RosterItem#roster_item.subscription == State#state.subscription,
	  RosterItem#roster_item.ask, State#state.pending_out} of
	{true, subscribe, true} -> ok;
	{true, undefined, false} -> ok;
	_ -> ct:fail({roster_item_mismatch, State, RosterItem})
    end.

%% RFC6121, A.2.1
transition(Id, Config, out, subscribe,
	   #state{subscription = Sub, pending_in = In, pending_out = Out} = State) ->
    PeerJID = ?config(peer, Config),
    PeerBareJID = jid:remove_resource(PeerJID),
    send(Config, #presence{id = Id, to = PeerBareJID, type = subscribe}),
    case {Sub, Out, In} of
	{none, false, _} ->
	    recv_push(Config, none, subscribe),
	    State#state{pending_out = true};
	{none, true, false} ->
	    %% BUG: we should not receive roster push here
	    recv_push(Config, none, subscribe),
	    State;
	{from, false, false} ->
	    recv_push(Config, from, subscribe),
	    State#state{pending_out = true};
	_ ->
	    State
    end;
%% RFC6121, A.2.2
transition(Id, Config, out, unsubscribe,
	   #state{subscription = Sub, pending_in = In, pending_out = Out} = State) ->
    PeerJID = ?config(peer, Config),
    PeerBareJID = jid:remove_resource(PeerJID),
    send(Config, #presence{id = Id, to = PeerBareJID, type = unsubscribe}),
    case {Sub, Out, In} of
	{none, true, _} ->
	    recv_push(Config, none, undefined),
	    State#state{pending_out = false};
	{to, false, _} ->
	    recv_push(Config, none, undefined),
	    recv_presence(Config, unavailable),
	    State#state{subscription = none, peer_available = false};
	{from, true, false} ->
	    recv_push(Config, from, undefined),
	    State#state{pending_out = false};
	{both, false, false} ->
	    recv_push(Config, from, undefined),
	    recv_presence(Config, unavailable),
	    State#state{subscription = from, peer_available = false};
	_ ->
	    State
    end;
%% RFC6121, A.2.3
transition(Id, Config, out, subscribed,
	    #state{subscription = Sub, pending_in = In, pending_out = Out} = State) ->
    PeerJID = ?config(peer, Config),
    PeerBareJID = jid:remove_resource(PeerJID),
    send(Config, #presence{id = Id, to = PeerBareJID, type = subscribed}),
    case {Sub, Out, In} of
	{none, false, true} ->
	    recv_push(Config, from, undefined),
	    State#state{subscription = from, pending_in = false};
	{none, true, true} ->
	    recv_push(Config, from, subscribe),
	    State#state{subscription = from, pending_in = false};
	{to, false, true} ->
	    recv_push(Config, both, undefined),
	    State#state{subscription = both, pending_in = false};
	{to, false, _} ->
	    %% BUG: we should not transition to 'both' state
	    recv_push(Config, both, undefined),
	    State#state{subscription = both};
	_ ->
	    State
    end;
%% RFC6121, A.2.4
transition(Id, Config, out, unsubscribed,
	   #state{subscription = Sub, pending_in = In, pending_out = Out} = State) ->
    PeerJID = ?config(peer, Config),
    PeerBareJID = jid:remove_resource(PeerJID),
    send(Config, #presence{id = Id, to = PeerBareJID, type = unsubscribed}),
    case {Sub, Out, In} of
	{none, false, true} ->
	    State#state{subscription = none, pending_in = false};
	{none, true, true} ->
	    recv_push(Config, none, subscribe),
	    State#state{subscription = none, pending_in = false};
	{to, _, true} ->
	    State#state{pending_in = false};
	{from, false, _} ->
	    recv_push(Config, none, undefined),
	    State#state{subscription = none};
	{from, true, _} ->
	    recv_push(Config, none, subscribe),
	    State#state{subscription = none};
	{both, _, _} ->
	    recv_push(Config, to, undefined),
	    State#state{subscription = to};
	_ ->
	    State
    end;
%% RFC6121, A.3.1
transition(_, Config, in, subscribe = Type,
	   #state{subscription = Sub, pending_in = In, pending_out = Out} = State) ->
    case {Sub, Out, In} of
	{none, false, false} ->
	    recv_subscription(Config, Type),
	    State#state{pending_in = true};
	{none, true, false} ->
	    recv_push(Config, none, subscribe),
	    recv_subscription(Config, Type),
	    State#state{pending_in = true};
	{to, false, false} ->
	    %% BUG: we should not receive roster push in this state!
	    recv_push(Config, to, undefined),
	    recv_subscription(Config, Type),
	    State#state{pending_in = true};
	_ ->
	    State
    end;
%% RFC6121, A.3.2
transition(_, Config, in, unsubscribe = Type,
	   #state{subscription = Sub, pending_in = In, pending_out = Out} = State) ->
    case {Sub, Out, In} of
	{none, _, true} ->
	    State#state{pending_in = false};
	{to, _, true} ->
	    recv_push(Config, to, undefined),
	    recv_subscription(Config, Type),
	    State#state{pending_in = false};
	{from, false, _} ->
	    recv_push(Config, none, undefined),
	    recv_subscription(Config, Type),
	    State#state{subscription = none};
	{from, true, _} ->
	    recv_push(Config, none, subscribe),
	    recv_subscription(Config, Type),
	    State#state{subscription = none};
	{both, _, _} ->
	    recv_push(Config, to, undefined),
	    recv_subscription(Config, Type),
	    State#state{subscription = to};
	_ ->
	    State
    end;
%% RFC6121, A.3.3
transition(_, Config, in, subscribed = Type,
	   #state{subscription = Sub, pending_in = In, pending_out = Out} = State) ->
    case {Sub, Out, In} of
	{none, true, _} ->
	    recv_push(Config, to, undefined),
	    recv_subscription(Config, Type),
	    recv_presence(Config, available),
	    State#state{subscription = to, pending_out = false, peer_available = true};
	{from, true, _} ->
	    recv_push(Config, both, undefined),
	    recv_subscription(Config, Type),
	    recv_presence(Config, available),
	    State#state{subscription = both, pending_out = false, peer_available = true};
	{from, false, _} ->
	    %% BUG: we should not transition to 'both' in this state
	    recv_push(Config, both, undefined),
	    recv_subscription(Config, Type),
	    recv_presence(Config, available),
	    State#state{subscription = both, pending_out = false, peer_available = true};
	_ ->
	    State
    end;
%% RFC6121, A.3.4
transition(_, Config, in, unsubscribed = Type,
	   #state{subscription = Sub, pending_in = In, pending_out = Out} = State) ->
    case {Sub, Out, In} of
	{none, true, true} ->
	    %% BUG: we should receive roster push in this state!
	    recv_subscription(Config, Type),
	    State#state{subscription = none, pending_out = false};
	{none, true, false} ->
	    recv_push(Config, none, undefined),
	    recv_subscription(Config, Type),
	    State#state{subscription = none, pending_out = false};
	{none, false, false} ->
	    State;
	{to, false, _} ->
	    recv_push(Config, none, undefined),
	    recv_presence(Config, unavailable),
	    recv_subscription(Config, Type),
	    State#state{subscription = none, peer_available = false};
	{from, true, false} ->
	    recv_push(Config, from, undefined),
	    recv_subscription(Config, Type),
	    State#state{subscription = from, pending_out = false};
	{both, _, _} ->
	    recv_push(Config, from, undefined),
	    recv_presence(Config, unavailable),
	    recv_subscription(Config, Type),
	    State#state{subscription = from, peer_available = false};
	_ ->
	    State
    end;
%% Outgoing roster remove
transition(Id, Config, out, remove,
	   #state{subscription = Sub, pending_in = In, pending_out = Out}) ->
    PeerJID = ?config(peer, Config),
    PeerBareJID = jid:remove_resource(PeerJID),
    Item = #roster_item{jid = PeerBareJID, subscription = remove},
    #iq{type = result, sub_els = []} =
	send_recv(Config, #iq{type = set, id = Id,
			      sub_els = [#roster_query{items = [Item]}]}),
    recv_push(Config, remove, undefined),
    case {Sub, Out, In} of
	{to, _, _} ->
	    recv_presence(Config, unavailable);
	{both, _, _} ->
	    recv_presence(Config, unavailable);
	_ ->
	    ok
    end,
    #state{};
%% Incoming roster remove
transition(_, Config, in, remove,
	   #state{subscription = Sub, pending_in = In, pending_out = Out} = State) ->
    case {Sub, Out, In} of
	{none, true, _} ->
	    ok;
	{from, false, _} ->
	    recv_push(Config, none, undefined),
	    recv_subscription(Config, unsubscribe);
	{from, true, _} ->
	    recv_push(Config, none, subscribe),
	    recv_subscription(Config, unsubscribe);
	{to, false, _} ->
	    %% BUG: we should receive push here
	    %% recv_push(Config, none, undefined),
	    recv_presence(Config, unavailable),
	    recv_subscription(Config, unsubscribed);
	{both, _, _} ->
	    recv_presence(Config, unavailable),
	    recv_push(Config, to, undefined),
	    recv_subscription(Config, unsubscribe),
	    recv_push(Config, none, undefined),
	    recv_subscription(Config, unsubscribed);
	_ ->
	    ok
    end,
    State#state{subscription = none}.

actions() ->
    States = [{Dir, Type} || Dir <- [out, in],
			     Type <- [subscribe, subscribed,
				      unsubscribe, unsubscribed,
				      remove]],
    Actions = lists:flatten([[X, Y] || X <- States, Y <- States]),
    remove_dups(Actions, []).

remove_dups([X|T], [X,X|_] = Acc) ->
    remove_dups(T, Acc);
remove_dups([X|T], Acc) ->
    remove_dups(T, [X|Acc]);
remove_dups([], Acc) ->
    lists:reverse(Acc).
