%%%----------------------------------------------------------------------
%%% File    : mod_invites.erl
%%% Author  : Stefan Strigler <stefan@strigler.de>
%%% Purpose : Account and Roster Invitation (aka Great Invitations)
%%% Created : Mon Sep 15 2025 by Stefan Strigler <stefan@strigler.de>
%%%
%%%
%%% ejabberd, Copyright (C) 2026 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(mod_invites).

-author('stefan@strigler.de').

-protocol({xep, 379, '0.3.3', '26.01', "complete", ""}).
-protocol({xep, 401, '0.5.0', '26.01', "complete", ""}).
-protocol({xep, 445, '0.2.0', '26.01', "complete", ""}).

-behaviour(gen_mod).

%% gen_mod
-export([depends/2, mod_doc/0, mod_options/1, mod_opt_type/1, reload/3, start/2, stop/1]).

%% hooks and callbacks
-export([adhoc_commands/4, adhoc_items/4, c2s_unauthenticated_packet/2, remove_user/2,
         s2s_receive_packet/1, sm_receive_packet/1, stream_feature_register/2]).

%% Service Discovery
-export([get_local_identity/5, get_local_features/5]).

%% commands
-export([cleanup_expired/0, expire_tokens/2, generate_invite/1, generate_invite/2, list_invites/1]).

%% helpers
-export([create_account_allowed/2, get_invite/2, get_invites/2, get_max_invites/2, is_create_allowed/2,
         is_expired/1, is_reserved/3, is_token_valid/2, roster_add/2, send_presence/3,
         set_invitee/3, set_invitee/5, token_uri/1, xdata_field/3]).

%% ejabberd_http
-export([process/2]).

-ifdef(TEST).
-export([create_roster_invite/2, create_account_invite/4, gen_invite/1, gen_invite/2, is_token_valid/3]).
-endif.

-include("logger.hrl").

-include_lib("xmpp/include/xmpp.hrl").

-include("ejabberd_commands.hrl").
-include("mod_invites.hrl").
-include("translate.hrl").

-type invite_token() :: #invite_token{}.

-callback cleanup_expired(Host :: binary()) -> non_neg_integer().
-callback create_invite(Invite :: invite_token()) -> invite_token().
-callback expire_tokens(User :: binary(), Server :: binary()) -> non_neg_integer().
-callback get_invite(Host :: binary(), Token :: binary()) ->
    invite_token() | {error, not_found}.
-callback get_invites(Host :: binary(), Inviter :: {User :: binary(), Host :: binary()}) ->
    [invite_token()].
-callback init(Host :: binary(), gen_mod:opts()) -> any().
-callback is_reserved(Host :: binary(), Token :: binary(), User :: binary()) -> boolean().
-callback is_token_valid(Host :: binary(), binary(), {binary(), binary()}) -> boolean().
-callback list_invites(Host :: binary()) -> [tuple()].
-callback remove_user(User :: binary(), Server :: binary()) -> any().
-callback set_invitee(Fun :: fun(() -> OkOrError),
                                Host :: binary(),
                                Token :: binary(),
                                Invitee :: binary(),
                                AccountName :: binary()) -> OkOrError | {error, conflict}
 when OkOrError :: ok | {error, term()}.

%% @format-begin

%%--------------------------------------------------------------------
%%| gen_mod callbacks

depends(_Host, _Opts) ->
    [{mod_adhoc, soft}, {mod_register, soft}, {mod_roster, soft}].

mod_doc() ->
    #{desc =>
          [?T("Allow User Invitation and Account Creation to create out-of-band "
              "links to onboard others onto the XMPP network and establish "
              "a mutual subscription."),
           ?T("This implements "
              "https://xmpp.org/extensions/xep-0379.html"
              "[XEP-0379: Pre-Authenticated Roster Subscription], "
              "https://xmpp.org/extensions/xep-0401.html"
              "[XEP-0401: Ad-hoc Account Invitation Generation], and "
              "https://xmpp.org/extensions/xep-0445.html"
              "[XEP-0445: Pre-Authenticated In-Band Registration]."),
           "",
           ?T("These invitations are created as XMPP URIs either via ad-hoc "
              "commands or via API commands (like _`generate_invite`_ API and "
              " _`generate_invite_with_username`_ API), are then meant to be "
              "sent out-of-band."),
           "",
           ?T("The receiving user should have installed a client that supports "
              "those invitations. Since this has proven to be a common obstacle "
              "for easy adoption, this module comes with an optional landing "
              "page parameter, that can either be some external service like an "
              "installation of "
              "https://github.com/modernxmpp/easy-xmpp-invitation[easy-xmpp-invitation], "
              "a third-party service like "
              "https://invite.joinjabber.org[JoinJabber] "
              "or for convenience a built-in service. This landing page will "
              "then guide the recipient with setting up a client "
              "and creating an account if required."),
           "",
           ?T("In order to use the included landing page feature, you have to"),
           "",
           ?T(" * have a copy of https://code.jquery.com/jquery-3.7.1.min.js[jQuery 3] and "
              "   https://github.com/twbs/bootstrap/releases/download/v4.6.2/bootstrap-4.6.2-dist.zip[Bootstrap 4] "
              "   in a shared directory on your system. If you're using Debian or "
              "   derivatives this is easiest accomplished by installing both "
              "   `libjs-jquery` and `libjs-bootstrap4` which will put them under "
              "   `/usr/share/javascript/{jquery,bootstrap4}`. Alternatively you can use "
              "   `tools/dl_invites_page_deps.sh <outdir>`."),
           ?T(" * in `ejabberd.yml` configure a listener for module `ejabberd_http` "
              "   with a request handler for `/share: mod_http_fileserver`"),
           ?T(" * in the `modules` section configure `mod_http_fileserver` so that "
              "   `docroot` points to the shared directory from above "
              "   (e.g. `docroot: /usr/share/javascript`)"),
           ?T(" * configure `mod_invites` and set `landing_page` to either `auto` "
              "   or an URL template like `https://{{ host }}/invites/{{ invite.token }}` "
              "   if your server setup includes a so called reverse proxy"),
           "",
           "If you'd rather want to use an external service, set `landing_page` "
           "to something like "
           "`http://{{ host }}:8080/easy-xmpp-invites/#{{ invite.uri|strip_protocol }}` "
           "or `https://invites.joinjabber.org/#{{ invite.uri|strip_protocol }}`."],
      note => "added in 26.01",
      opts =>
          [{access_create_account,
            #{value => ?T("Access Rule Name"),
              desc =>
                  ?T("This is the name of an access rule that specifies who is allowed to create "
                     "invites of `create account`. The default value is `none`, i.e. nobody is able to"
                     " create such invites. Furthermore it applies to 'roster invites' and allows "
                     "to do in-band registration (IBR) if the sending user is allowed by this rule. "
                     "Users from the `admin` ACL are always allowed to create those invites."),
              example => ["mod_invites:", "  access_create_account: local"]}},
           {db_type,
            #{value => "mnesia | sql",
              desc =>
                  ?T("Same as top-level _`default_db`_ option, but applied to this "
                     "module only.")}},
           {landing_page,
            #{value => "none | auto | LandingPageURLTemplate",
              desc =>
                  ?T("Whether or not to use a landing page for the invites that "
                     "are being created. If using a template URL this can be "
                     "either be external or internal. Template variables include "
                     "`host`, `invite.token` and `invite.uri`, there are also "
                     "filters defined, most notably `strip_protocol`. Here's an example: "
                     "`http://{{ host }}:8080/easy-xmpp-invites/#{{ invite.uri|strip_protocol }}`. "
                     "For convenience you can choose `auto` here and the "
                     "`ejabberd_http` handler for `mod_invites` will  be used to "
                     "construct the landing page URL. Default is `none`.")}},
           {max_invites,
            #{value => "pos_integer() | infinity",
              desc =>
                  ?T("Maximum number of 'create account' invites that can be created "
                     "by an individual user. Users that match the `admin` ACL are "
                     "exempt from this limitation. Furthermore it restricts the use of "
                     "`roster invites` for account creation. Default is `infinity`.")}},
           {site_name,
            #{value => ?T("Site Name"),
              desc =>
                  ?T("A human readable name for your site. E.g. `\"My Beautiful Laundrette\"`. "
                     "Used in landing page templates.")}},
           {templates_dir,
            #{value => ?T("Path"),
              desc =>
                  ?T("The directory containing templates and static files used "
                     "for landing page and web registration form. Only needs to "
                     "be set if you want to ship your own set of templates or "
                     "list of recommended apps.")}},
           {token_expire_seconds,
            #{value => "pos_integer()",
              desc =>
                  ?T("Number of seconds until token expires. Default value "
                     "is `432000` (that is five days: `5 * 24 * 60 * 60`)")}}],
      example =>
          [{?T("Basic configuration with landing page but without creating "
               "accounts, just roster invites:"),
            ["listen: ",
             "  -",
             "    port: 5281",
             "    module: ejabberd_http",
             "    request_handlers:",
             "      /invites: mod_invites",
             "      /share: mod_http_fileserver",
             "# [...]",
             "modules:",
             "  mod_http_fileserver:",
             "    docroot: /usr/share/javascript",
             "  mod_invites:",
             "    landing_page: auto"]},
           {?T("To allow only admin users to create invites of 'create account' and "
               "disable regular in-band registration, you would have a config like this:"),
            ["acl:",
             "  admin:",
             "    - user: \"my_admin_user@example.com\"",
             "",
             "access_rules:",
             "  register:",
             "    allow: admin",
             "",
             "modules:",
             "  mod_invites:",
             "    landing_page: auto",
             "  mod_register:",
             "    allow_modules:",
             "      - mod_invites"]},
           {?T("If you want all your users to be able to send 'create account' "
               "invites, you would configure your server like this instead. "
               "Note that the names of the access rules are just examples and "
               "you're free to change them."),
            ["acl:",
             "  local:",
             "    user_regexp: \"\"",
             "access_rules:",
             "  create_account_invite:",
             "    allow: local",
             "",
             "modules:",
             "  mod_invites:",
             "    access_create_account: create_account_invite",
             "    landing_page: auto",
             "  mod_register:",
             "    allow_modules:",
             "      - mod_invites"]}]}.

mod_options(Host) ->
    [{access_create_account, none},
     {db_type, ejabberd_config:default_db(Host, ?MODULE)},
     {landing_page, none},
     {max_invites, infinity},
     {site_name, Host},
     {templates_dir, filename:join([code:priv_dir(ejabberd), ?MODULE, <<>>])},
     {token_expire_seconds, ?INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT}].

reload(ServerHost, NewOpts, OldOpts) ->
    NewMod = gen_mod:db_mod(NewOpts, ?MODULE),
    OldMod = gen_mod:db_mod(OldOpts, ?MODULE),
    if NewMod /= OldMod ->
           NewMod:init(ServerHost, NewOpts);
       true ->
           ok
    end.

start(Host, Opts) ->
    Mod = gen_mod:db_mod(Opts, ?MODULE),
    Mod:init(Host, Opts),
    {ok,
     [{hook, remove_user, remove_user, 50},
      {hook, adhoc_local_items, adhoc_items, 50},
      {hook, adhoc_local_commands, adhoc_commands, 50},
      {hook, disco_local_features, get_local_features, 50},
      {hook, disco_local_identity, get_local_identity, 50},
      {hook, s2s_receive_packet, s2s_receive_packet, 50},
      {hook, sm_receive_packet, sm_receive_packet, 50},
      {hook, c2s_pre_auth_features, stream_feature_register, 50},
      %% note the sequence below is important
      {hook, c2s_unauthenticated_packet, c2s_unauthenticated_packet, 10},
      {commands, get_commands_spec()}]}.

stop(_Host) ->
    ok.

mod_opt_type(access_create_account) ->
    econf:acl();
mod_opt_type(db_type) ->
    econf:db_type(?MODULE);
mod_opt_type(landing_page) ->
    econf:either(
        econf:enum([none, auto]), econf:binary());
mod_opt_type(max_invites) ->
    econf:pos_int(infinity);
mod_opt_type(site_name) ->
    econf:binary();
mod_opt_type(templates_dir) ->
    econf:directory();
mod_opt_type(token_expire_seconds) ->
    econf:pos_int().

%%--------------------------------------------------------------------
%%| ejabberd command callbacks

-spec get_commands_spec() -> [ejabberd_commands()].
get_commands_spec() ->
    [#ejabberd_commands{name = cleanup_expired_invite_tokens,
                        tags = [purge],
                        desc = "Delete invite tokens that have expired",
                        module = ?MODULE,
                        function = cleanup_expired,
                        note = "added in 26.01",
                        args = [],
                        result_example = 42,
                        result = {num_deleted, integer}},
     #ejabberd_commands{name = expire_invite_tokens,
                        tags = [purge],
                        desc =
                            "Sets expiration to a date in the past for all tokens belonging "
                            "to user",
                        module = ?MODULE,
                        function = expire_tokens,
                        note = "added in 26.01",
                        args = [{username, binary}, {host, binary}],
                        result_example = 42,
                        result = {num_deleted, integer}},
     #ejabberd_commands{name = generate_invite,
                        tags = [accounts],
                        desc = "Create a new 'create account' invite",
                        module = ?MODULE,
                        function = generate_invite,
                        note = "added in 26.01",
                        args = [{host, binary}],
                        args_desc = ["Hostname to generate 'create account' invite for."],
                        args_example = [<<"example.com">>],
                        result_example =
                            {<<"xmpp:example.com?register;preauth=4bsdpwVrRDQYnF9aQQKXGbF7">>,
                             <<"https://example.com/invites/4bsdpwVrRDQYnF9aQQKXGbF7">>},
                        result = {invite, {tuple, [{invite_uri, string}, {landing_page, string}]}}},
     #ejabberd_commands{name = generate_invite_with_username,
                        tags = [accounts],
                        desc =
                            "Create a new 'create account' invite token with a preselected "
                            "username",
                        module = ?MODULE,
                        function = generate_invite,
                        note = "added in 26.01",
                        args = [{username, binary}, {host, binary}],
                        args_desc =
                            ["Preselected Username",
                             "hostname  to generate 'create account' invite for."],
                        args_example = [<<"juliet">>, <<"example.com">>],
                        result_example =
                            {<<"xmpp:juliet@example.com?register;preauth=4bsdpwVrRDQYnF9aQQKXGbF7">>,
                             <<"https://example.com/invites/4bsdpwVrRDQYnF9aQQKXGbF7">>},
                        result = {invite, {tuple, [{invite_uri, string}, {landing_page, string}]}}},
     #ejabberd_commands{name = list_invites,
                        tags = [accounts],
                        desc = "List invite tokens",
                        module = ?MODULE,
                        function = list_invites,
                        note = "added in 26.01",
                        args = [{host, binary}],
                        args_desc = ["Hostname tokens are valid for"],
                        args_example = [<<"example.com">>],
                        %result_example = [{invite_token, invite}],
                        result =
                            {invites,
                             {list,
                              {invite,
                               {tuple,
                                [{token, string},
                                 {valid, atom},
                                 {created_at, string},
                                 {expires, string},
                                 {type, atom},
                                 {inviter, string},
                                 {invitee, string},
                                 {account_name, string},
                                 {token_uri, string},
                                 {landing_page, string}]}}}}}].

cleanup_expired() ->
    lists:foldl(fun(Host, Count) ->
                   case gen_mod:is_loaded(Host, ?MODULE) of
                       true ->
                           Count + db_call(Host, cleanup_expired, [Host]);
                       false ->
                           Count
                   end
                end,
                0,
                ejabberd_option:hosts()).

-spec expire_tokens(binary(), binary()) -> non_neg_integer().
expire_tokens(User0, Server0) ->
    User = jid:nodeprep(User0),
    Server = jid:nameprep(Server0),
    pretty_format_command_result(try_db_call(Server, expire_tokens, [User, Server])).

-spec generate_invite(binary()) -> binary() | {error, any()}.
generate_invite(Host) ->
    generate_invite(<<>>, Host).

-spec generate_invite(binary(), binary()) -> binary() | {error, any()}.
generate_invite(AccountName, Host) ->
    pretty_format_command_result(gen_invite(AccountName, Host)).

-ifdef(TEST).

-spec gen_invite(binary()) -> binary() | {error, any()}.
gen_invite(Host) ->
    gen_invite(<<>>, Host).

-endif.

-spec gen_invite(binary(), binary()) -> binary() | {error, any()}.
gen_invite(AccountName, Host0) ->
    Host = jid:nameprep(Host0),
    case create_account_invite(Host, {<<>>, Host}, AccountName, false) of
        {error, {module_not_loaded, ?MODULE, Host}} ->
            {error, host_unknown};
        {error, _Reason} = Error ->
            Error;
        Invite ->
            {token_uri(Invite), landing_page(Host, Invite)}
    end.

list_invites(Host) ->
    Res = maybe
              {ok, Invites} ?= try_db_call(Host, list_invites, [Host]),
              [format_invite(Host, Invite) || Invite <- Invites]
          end,
    pretty_format_command_result(Res).

format_invite(Host,
              #invite_token{token = TO,
                            inviter = {IU, IS},
                            invitee = IE,
                            created_at = CA,
                            expires = Exp,
                            type = TY,
                            account_name = AN} =
                  Invite) ->
    {TO,
     is_token_valid(Host, TO),
     encode_datetime(CA),
     encode_datetime(Exp),
     TY,
     jid:encode(
         jid:make(IU, IS)),
     IE,
     AN,
     token_uri(Invite),
     landing_page(Host, Invite)}.

%%--------------------------------------------------------------------
%%| hooks and callbacks

remove_user(User, Server) ->
    LUser = jid:nodeprep(User),
    LServer = jid:nameprep(Server),
    db_call(Server, remove_user, [LUser, LServer]).

%% ---

-spec adhoc_items(empty | {error, stanza_error()} | {result, [disco_item()]},
                  jid(),
                  jid(),
                  binary()) ->
                     {error, stanza_error()} | {result, [disco_item()]} | empty.
adhoc_items(Acc,
            #jid{lserver = LServer} = From,
            #jid{lserver = LServer, server = Server} = _To,
            Lang) ->
    InviteUser =
        #disco_item{jid = jid:make(Server),
                    node = ?NS_INVITE_INVITE,
                    name = trans(Lang, ?T("Invite User"))},
    CreateAccount =
        #disco_item{jid = jid:make(Server),
                    node = ?NS_INVITE_CREATE_ACCOUNT,
                    name = trans(Lang, ?T("Create Account"))},
    MyItems =
        case create_account_allowed(LServer, From) of
            ok ->
                [InviteUser, CreateAccount];
            {error, not_allowed} ->
                [InviteUser]
        end,
    case Acc of
        {result, AccItems} ->
            {result, AccItems ++ MyItems};
        _ ->
            {result, MyItems}
    end;
adhoc_items(Acc, _From, _To, _Lang) ->
    Acc.

%% ---

-spec adhoc_commands(empty | adhoc_command(), jid(), jid(), adhoc_command()) ->
                        adhoc_command() | {error, stanza_error()}.
adhoc_commands(_Acc,
               #jid{luser = LUser, lserver = LServer},
               #jid{lserver = LServer},
               #adhoc_command{node = ?NS_INVITE_INVITE = Node,
                              action = execute,
                              sid = SID,
                              lang = Lang}) ->
    Invite = create_roster_invite(LServer, {LUser, LServer}),
    XData =
        #xdata{type = result,
               title = trans(Lang, <<"New Invite Token Created">>),
               fields =
                   maybe_add_landing_url(LServer,
                                         Invite,
                                         Lang,
                                         [#xdata_field{var = <<"uri">>,
                                                       label = trans(Lang, <<"Invite URI">>),
                                                       type = 'text-single',
                                                       values = [token_uri(Invite)]},
                                          #xdata_field{var = <<"expire">>,
                                                       label =
                                                           trans(Lang,
                                                                 <<"Invite token valid until">>),
                                                       type = 'text-single',
                                                       values =
                                                           [encode_datetime(Invite#invite_token.expires)]}])},
    Result =
        #adhoc_command{status = completed,
                       node = Node,
                       xdata = XData,
                       sid = SID},
    {stop, Result};
adhoc_commands(_Acc,
               #jid{luser = LUser, lserver = LServer} = From,
               #jid{lserver = LServer},
               #adhoc_command{node = ?NS_INVITE_CREATE_ACCOUNT = Node,
                              sid = SID,
                              lang = Lang,
                              xdata = #xdata{type = submit, fields = Fields}}) ->
    check(fun create_account_allowed/2,
          [LServer, From],
          fun() ->
             AccountName = xdata_field(<<"username">>, Fields, <<>>),
             Invite =
                 create_account_invite(LServer,
                                       {LUser, LServer},
                                       AccountName,
                                       to_boolean(xdata_field(<<"roster-subscription">>,
                                                              Fields,
                                                              false))),
             case Invite of
                 {error, Reason} ->
                     {stop, {error, to_stanza_error(Lang, Reason)}};
                 _Invite ->
                     ResultFields =
                         maybe_add_landing_url(LServer,
                                               Invite,
                                               Lang,
                                               [#xdata_field{var = <<"uri">>,
                                                             label = trans(Lang, <<"Invite URI">>),
                                                             type = 'text-single',
                                                             values = [token_uri(Invite)]},
                                                #xdata_field{var = <<"expire">>,
                                                             label =
                                                                 trans(Lang,
                                                                       <<"Invite token valid until">>),
                                                             type = 'text-single',
                                                             values =
                                                                 [encode_datetime(Invite#invite_token.expires)]}]),
                     ResultXData = #xdata{type = result, fields = ResultFields},
                     Result =
                         #adhoc_command{status = completed,
                                        sid = SID,
                                        node = Node,
                                        xdata = ResultXData},
                     {stop, Result}
             end
          end,
          fun(Reason) -> {stop, {error, to_stanza_error(Lang, Reason)}} end);
adhoc_commands(_Acc,
               #jid{lserver = LServer} = From,
               #jid{lserver = LServer},
               #adhoc_command{node = ?NS_INVITE_CREATE_ACCOUNT = Node,
                              action = execute,
                              sid = SID,
                              lang = Lang}) ->
    check(fun create_account_allowed/2,
          [LServer, From],
          fun() ->
             XData =
                 #xdata{type = form,
                        title = trans(Lang, <<"Account Creation Invite">>),
                        fields =
                            [#xdata_field{var = <<"username">>,
                                          label = trans(Lang, <<"Username">>),
                                          type = 'text-single'},
                             #xdata_field{var = <<"roster-subscription">>,
                                          label = trans(Lang, <<"Roster Subscription">>),
                                          type = boolean}]},
             Actions = #adhoc_actions{execute = complete, complete = true},
             Result =
                 #adhoc_command{status = executing,
                                node = Node,
                                sid = maybe_gen_sid(SID),
                                actions = Actions,
                                xdata = XData},
             {stop, Result}
          end,
          fun(Reason) -> {stop, {error, to_stanza_error(Lang, Reason)}} end);
adhoc_commands(Acc, _From, _To, _Command) ->
    Acc.

-spec s2s_receive_packet({stanza() | drop, State}) ->
                            {stanza() | drop, State} | {stop, {drop, State}}
    when State :: ejabberd_s2s_in:state().
s2s_receive_packet({Stanza, State}) ->
    case sm_receive_packet(Stanza) of
        {stop, drop} ->
            {stop, {drop, State}};
        Res ->
            {Res, State}
    end.

-spec sm_receive_packet(stanza() | drop) -> stanza() | drop | {stop, drop}.
sm_receive_packet(#presence{from = From,
                            to = To,
                            type = subscribe,
                            sub_els = Els} =
                      Presence) ->
    case handle_pre_auth_token(Els, To, From) of
        true ->
            {stop, drop};
        false ->
            Presence
    end;
sm_receive_packet(Other) ->
    Other.

handle_pre_auth_token([], _To, _From) ->
    false;
handle_pre_auth_token([El | Els],
                      #jid{luser = LUser, lserver = LServer} = To,
                      FromFullJid) ->
    From = jid:remove_resource(FromFullJid),
    try xmpp:decode(El) of
        #preauth{token = Token} = PreAuth ->
            ?DEBUG("got preauth token: ~p", [PreAuth]),
            case is_token_valid(LServer, Token, {LUser, LServer}) of
                true ->
                    roster_add(To, From),
                    send_presence(To, From, subscribed),
                    send_presence(To, From, subscribe),
                    set_invitee(LServer, Token, From),
                    true;
                false ->
                    ?INFO_MSG("Got invalid preauth token from ~s: ~p", [jid:encode(From), PreAuth]),
                    false
            end;
        _Other ->
            handle_pre_auth_token(Els, To, From)
    catch
        _:{xmpp_codec, _} ->
            handle_pre_auth_token(Els, To, From)
    end.

%%--------------------------------------------------------------------
%%| Service Disco

-define(INFO_IDENTITY(Category, Type, Name, Lang),
        [#identity{category = Category,
                   type = Type,
                   name = trans(Lang, Name)}]).
-define(INFO_COMMAND(Name, Lang),
        ?INFO_IDENTITY(<<"automation">>, <<"command-node">>, Name, Lang)).

-spec get_local_identity([identity()], jid(), jid(), binary(), binary()) -> [identity()].
get_local_identity(_Acc, _From, _To, ?NS_INVITE_CREATE_ACCOUNT, Lang) ->
    ?INFO_COMMAND(?T("Create Account"), Lang);
get_local_identity(_Acc, _From, _To, ?NS_INVITE_INVITE, Lang) ->
    ?INFO_COMMAND(?T("Invite User"), Lang);
get_local_identity(Acc, _From, _To, _NS, _Lang) ->
    Acc.

-spec get_local_features(mod_disco:features_acc(), jid(), jid(), binary(), binary()) ->
                            mod_disco:features_acc().
get_local_features(Acc, From, #jid{lserver = LServer} = _To, Ns, Lang) ->
    maybe
        allow ?=
            case Ns of
                ?NS_INVITE_CREATE_ACCOUNT ->
                    Access = mod_invites_opt:access_create_account(LServer),
                    acl:match_rule(LServer, Access, From);
                ?NS_INVITE_INVITE ->
                    allow;
                _ ->
                    false
            end,
        {result, [?NS_COMMANDS]}
    else
        false ->
            Acc;
        deny ->
            {error, xmpp:err_forbidden(?T("Access denied by service policy"), Lang)}
    end.

%%--------------------------------------------------------------------
%%| ibr hooks
stream_feature_register(Acc, Host) ->
    case gen_mod:is_loaded(Host, ?MODULE) of
        true ->
            mod_invites_register:stream_feature_register(Acc, Host);
        false ->
            Acc
    end.

c2s_unauthenticated_packet(State, IQ) ->
    mod_invites_register:c2s_unauthenticated_packet(State, IQ).

%%--------------------------------------------------------------------
%%| ejabberd_http
process(LocalPath, Request) ->
    mod_invites_http:process(LocalPath, Request).

%%--------------------------------------------------------------------
%%| helpers
get_invite(Host, Token) ->
    db_call(Host, get_invite, [Host, Token]).

get_invites(Host, Inviter) ->
    db_call(Host, get_invites, [Host, Inviter]).

is_expired(#invite_token{expires = Expires}) ->
    Now = erlang:timestamp(),
    calendar:datetime_to_gregorian_seconds(Expires)
    < calendar:datetime_to_gregorian_seconds(
          calendar:now_to_universal_time(Now)).

is_reserved(Host, Token, User) ->
    db_call(Host, is_reserved, [Host, Token, User]).

-spec is_token_valid(binary(), binary()) -> boolean().
is_token_valid(Host, Token) ->
    is_token_valid(Host, Token, {<<>>, Host}).

-spec is_token_valid(binary(), binary(), {binary(), binary()}) -> boolean().
is_token_valid(Host, Token, Inviter) ->
    db_call(Host, is_token_valid, [Host, Token, Inviter]).

-spec set_invitee(binary(), binary(), jid() | binary()) -> ok.
set_invitee(Host, Token, #jid{} = InviteeJid) ->
    set_invitee(Host,
                Token,
                jid:encode(
                    jid:remove_resource(InviteeJid)),
                <<>>);
set_invitee(Host, Token, Invitee) ->
    set_invitee(Host, Token, Invitee, <<>>).

set_invitee(Host, Token, Invitee, AccountName) ->
    set_invitee(fun() -> ok end, Host, Token, Invitee, AccountName).

-spec set_invitee(binary(), binary(), binary(), binary()) -> ok.
set_invitee(F, Host, Token, Invitee, AccountName) ->
    %% This invalidates the invite token if Invitee isn't empty
    db_call(Host, set_invitee, [F, Host, Token, Invitee, AccountName]).

create_roster_invite(Host, Inviter) ->
    create_invite(roster_only, Host, Inviter, <<>>).

create_account_invite(Host, Inviter, AccountName, _Subscribe = true) ->
    create_invite(account_subscription, Host, Inviter, AccountName);
create_account_invite(Host, Inviter, AccountName, _Subcribe = false) ->
    create_invite(account_only, Host, Inviter, AccountName).

create_invite(Type, Host, Inviter, AccountName) ->
    try invite_token(Type, Host, Inviter, AccountName) of
        Invite ->
            ?DEBUG("Created invite: ~p", [Invite]),
            db_call(Host, create_invite, [Invite])
    catch
        _:({error, _Reason} = Error) ->
            Error;
        _:Error ->
            {error, Error}
    end.

check_account_name(<<>>, _) ->
    <<>>;
check_account_name(error, _) ->
    {error, account_name_invalid};
check_account_name(_, error) ->
    {error, hostname_invalid};
check_account_name(AccountName, Host) ->
    MyHosts = ejabberd_option:hosts(),
    case lists:member(Host, MyHosts) of
        false ->
            {error, host_unknown};
        true ->
            case ejabberd_auth:user_exists(AccountName, Host) of
                true ->
                    {error, user_exists};
                false ->
                    case is_reserved(Host, <<>>, AccountName) of
                        true ->
                            {error, reserved};
                        false ->
                            AccountName
                    end
            end
    end.

check_max_invites(roster_only, _) ->
    ok;
check_max_invites(_Type, {User, Host}) ->
    case is_create_allowed(User, Host) of
        true ->
            ok;
        false ->
            {error, num_invites_exceeded}
    end.

is_create_allowed(User, Host) ->
    case get_max_invites(User, Host) of
        infinity ->
            true;
        MaxInvites ->
            Invites = get_invites(Host, {User, Host}),
            NumCreated =
                lists:foldl(fun (#invite_token{type = roster_only, account_name = <<>>}, Num) ->
                                    Num;
                                (#invite_token{type = roster_only}, Num) ->
                                    %% We make sure to set account_name to the registered name when
                                    %% creating the account. This field is not used in roster_only
                                    %% scenario otherwise.
                                    Num + 1;
                                (#invite_token{invitee = <<>>} = Invite, Num) ->
                                    %% account create tokens count unless they haven't been used and
                                    %% are expired
                                    case mod_invites:is_expired(Invite) of
                                        true ->
                                            Num;
                                        false ->
                                            Num + 1
                                    end;
                                (_, Num) ->
                                    %% account create token where invitee is not empty
                                    Num + 1
                            end,
                            0,
                            Invites),
            NumCreated < MaxInvites
    end.

get_max_invites(<<>>, _Server) ->
    infinity;
get_max_invites(User, Server) ->
    case {mod_invites_opt:max_invites(Server),
          acl:match_acl(Server, {acl, admin}, #{usr => {User, Server, <<>>}})}
    of
        {infinity, _} ->
            infinity;
        {_, true} ->
            infinity;
        {MaxInvites, false} ->
            MaxInvites
    end.

maybe_throw({error, _} = Error) ->
    throw(Error);
maybe_throw(Good) ->
    Good.

invite_token(Type, Host, Inviter, AccountName0) ->
    maybe_throw(check_max_invites(Type, Inviter)),
    Token = p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT),
    AccountName = maybe_throw(check_account_name(jid:nodeprep(AccountName0), Host)),
    set_token_expires(#invite_token{token = Token,
                                    inviter = Inviter,
                                    type = Type,
                                    account_name = AccountName},
                      mod_invites_opt:token_expire_seconds(Host)).

token_uri(#invite_token{type = Type,
                        token = Token,
                        account_name = AccountName,
                        inviter = {_User, Host}})
    when Type =:= account_only; Type =:= account_subscription ->
    Invitee =
        case AccountName of
            <<>> ->
                Host;
            _ ->
                <<AccountName/binary, "@", Host/binary>>
        end,
    <<"xmpp:", Invitee/binary, "?register;preauth=", Token/binary>>;
token_uri(#invite_token{type = roster_only,
                        token = Token,
                        inviter = {User, Host}}) ->
    IBR = maybe_add_ibr_allowed(User, Host),
    Inviter =
        jid:encode(
            jid:make(User, Host)),
    <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary, IBR/binary>>.

maybe_add_ibr_allowed(User, Host) ->
    case create_account_allowed(Host, jid:make(User, Host)) of
        ok ->
            <<";ibr=y">>;
        {error, not_allowed} ->
            <<>>
    end.

landing_page(Host, Invite) ->
    mod_invites_http:landing_page(Host, Invite).

-spec db_call(binary(), atom(), [any()]) -> any().
db_call(Host, Fun, Args) ->
    Mod = gen_mod:db_mod(Host, ?MODULE),
    apply(Mod, Fun, Args).

%% father forgive me
lift({error, _R} = E) ->
    E;
lift({ok, _V} = R) ->
    R;
lift(Res) ->
    {ok, Res}.

-spec try_db_call(Host :: binary(), Fun :: atom(), Args :: [any()]) ->
                     {ok, any()} | {error, any()}.
try_db_call(Host, Fun, Args) ->
    try
        lift(db_call(Host, Fun, Args))
    catch
        error:({error, _Reason} = Error) ->
            Error;
        error:Error ->
            {error, Error}
    end.

-spec trans(binary(), binary()) -> binary().
trans(Lang, Msg) ->
    translate:translate(Lang, Msg).

-spec encode_datetime(calendar:datetime()) -> binary().
encode_datetime({{Year, Month, Day}, {Hour, Minute, Second}}) ->
    list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
                                 [Year, Month, Day, Hour, Minute, Second])).

set_token_expires(#invite_token{created_at = CreatedAt} = Invite, ExpireSecs) ->
    Invite#invite_token{expires =
                            calendar:gregorian_seconds_to_datetime(calendar:datetime_to_gregorian_seconds(CreatedAt)
                                                                   + ExpireSecs)}.

xdata_field(_Field, [], Default) ->
    Default;
xdata_field(Field, [#xdata_field{var = Field, values = [<<>> | _]} | _], Default) ->
    Default;
xdata_field(Field, [#xdata_field{var = Field, values = [Result | _]} | _], _Default) ->
    Result;
xdata_field(Field, [_NoMatch | Fields], Default) ->
    xdata_field(Field, Fields, Default).

maybe_add_landing_url(Host, Invite, Lang, XData) ->
    case landing_page(Host, Invite) of
        <<>> ->
            XData;
        LandingPage ->
            [#xdata_field{var = <<"landing-url">>,
                          values = [LandingPage],
                          label = trans(Lang, <<"Invite Landing Page URL">>),
                          type = 'text-single'}
             | XData]
    end.

check(Check, Args, Fun, Else) ->
    case erlang:apply(Check, Args) of
        ok ->
            Fun();
        {error, Reason} ->
            Else(Reason)
    end.

create_account_allowed(Host, User) ->
    case mod_invites_opt:access_create_account(Host) of
        none ->
            {error, not_allowed};
        Access ->
            case acl:match_rule(Host, Access, User) of
                deny ->
                    {error, not_allowed};
                allow ->
                    ok
            end
    end.

to_boolean(Boolean) when is_boolean(Boolean) ->
    Boolean;
to_boolean(True) when True == <<"1">>; True == <<"true">> ->
    true;
to_boolean(False) when False == <<"0">>; False == <<"false">> ->
    false.

to_stanza_error(Lang, not_allowed) ->
    Text = trans(Lang, <<"Access forbidden">>),
    xmpp:err_forbidden(Text, Lang);
to_stanza_error(Lang, Reason) ->
    Text = trans(Lang, reason_to_text(Reason)),
    xmpp:err_bad_request(Text, Lang).

reason_to_text(account_name_invalid) ->
    ?T("Username invalid");
reason_to_text(host_unknown) ->
    ?T("Host unknown");
reason_to_text(hostname_invalid) ->
    ?T("Hostname invalid");
reason_to_text(num_invites_exceeded) ->
    ?T("Maximum number of invites reached");
reason_to_text(reserved) ->
    ?T("Username is reserved");
reason_to_text(user_exists) ->
    ?T("User already exists").

maybe_gen_sid(<<>>) ->
    p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT);
maybe_gen_sid(SID) ->
    SID.

roster_add(UserJID, RosterItemJID) ->
    RosterItem =
        #roster_item{jid = RosterItemJID,
                     subscription = from,
                     ask = subscribe},
    mod_roster:set_item_and_notify_clients(UserJID, RosterItem, true).

send_presence(From, To, Type) ->
    Presence =
        #presence{from = From,
                  to = To,
                  type = Type},
    ejabberd_router:route(Presence).

pretty_format_command_result({error, {module_not_loaded, ?MODULE, Host}}) ->
    {error,
     lists:flatten(
         io_lib:format("Virtual host not known: ~s", [binary_to_list(Host)]))};
pretty_format_command_result({error, host_unknown}) ->
    {error, "Virtual host not known"};
pretty_format_command_result({error, user_exists}) ->
    {error, "Username already taken"};
pretty_format_command_result({ok, Result}) ->
    Result;
pretty_format_command_result(Result) ->
    Result.
