%%%-------------------------------------------------------------------
%%% @author Sergey <me@seriyps.ru>
%%% @copyright (C) 2018, Sergey
%%% @doc
%%% Process that manages pool of connections to telegram datacenter
%%% and is responsible for load-balancing between them
%%% @end
%%% TODO: monitoring of DC connections! Make 100% sure they are killed when pool
%%% is killed. Maybe link?
%%% Created : 14 Oct 2018 by Sergey <me@seriyps.ru>
%%%-------------------------------------------------------------------
-module(mtp_dc_pool).

-behaviour(gen_server).

%% API
-export([start_link/1,
         get/3,
         return/2,
         add_connection/1,
         ack_connected/2,
         status/1,
         valid_dc_id/1,
         dc_to_pool_name/1]).

%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
         terminate/2, code_change/3]).
-export_type([status/0]).

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

-define(SERVER, ?MODULE).
-define(APP, mtproto_proxy).
-define(BURST_MAX, 10).

-type upstream() :: mtp_handler:handle().
-type downstream() :: mtp_down_conn:handle().
-type ds_store() :: psq:psq().
-type status() :: #{n_downstreams := non_neg_integer(),
                    n_upstreams := non_neg_integer(),
                    min := non_neg_integer(),
                    max := non_neg_integer(),
                    dc_id := mtp_config:dc_id()}.

-record(state,
        {dc_id :: mtp_config:dc_id(),
         %% This one might be really big:
         upstreams = #{} :: #{upstream() => {downstream(), Monitor :: reference()}},
         %% On-demand downstreams are started asynchronously;
         pending_downstreams = [] :: [pid()],
         %% Downstream storage that allows to choose the one with minimal
         %% number of connections
         %% Should be relatively small
         downstreams :: ds_store(),
         downstream_monitors = #{} :: #{reference() => downstream()}
        }).

%%%===================================================================
%%% API
%%%===================================================================
start_link(DcId) ->
    gen_server:start_link({local, dc_to_pool_name(DcId)}, ?MODULE, DcId, []).

valid_dc_id(DcId) ->
    is_integer(DcId) andalso
        -10 < DcId andalso
        10 > DcId.

dc_to_pool_name(DcId)  ->
    valid_dc_id(DcId) orelse error(invalid_dc_id, [DcId]),
    binary_to_atom(<<"mtp_dc_pool_", (integer_to_binary(DcId))/binary>>, utf8).


get(Pool, Upstream, #{addr := _} = Opts) ->
    gen_server:call(Pool, {get, Upstream, Opts}).

return(Pool, Upstream) ->
    gen_server:cast(Pool, {return, Upstream}).

add_connection(Pool) ->
    gen_server:call(Pool, add_connection, 10000).

ack_connected(Pool, Downstream) ->
    gen_server:cast(Pool, {connected, Downstream}).

-spec status(pid()) -> status().
status(Pool) ->
    gen_server:call(Pool, status).

%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
init(DcId) ->
    InitConnections = application:get_env(mtproto_proxy, init_dc_connections, 4),
    State = #state{dc_id = DcId,
                   downstreams = ds_new([])},
    State1 = connect_many(InitConnections, State),
    State2 = wait_pending(State1),
    {ok, State2}.

handle_call({get, Upstream, Opts}, _From, State) ->
    {Downstream, State1} = handle_get(Upstream, Opts, State),
    {reply, Downstream, State1};
handle_call(add_connection, _From, State) ->
    State1 = connect(State),
    {reply, ok, State1};
handle_call(status, _From, #state{downstreams = Ds,
                                  upstreams = Us,
                                  dc_id = DcId} = State) ->
    {NDowns, NUps, Min, Max} =
        ds_fold(
          fun(_Pid, N, {NDowns, NUps, Min, Max}) ->
                  {NDowns + 1, NUps + N, min(Min, N), max(Max, N)}
          end, {0, 0, map_size(Us), 0}, Ds),
    {reply, #{n_downstreams => NDowns,
              n_upstreams => NUps,
              min => Min,
              max => Max,
              dc_id => DcId}, State}.

handle_cast({return, Upstream}, State) ->
    {noreply, handle_return(Upstream, State)};
handle_cast({connected, Pid}, State) ->
    {noreply, handle_connected(Pid, State)}.

handle_info({'DOWN', MonitorRef, process, Pid, Reason}, State) ->
    {noreply, handle_down(MonitorRef, Pid, Reason, State)}.
terminate(_Reason, #state{downstreams = Ds}) ->
    ds_fold(
      fun(Pid, _, _) ->
              mtp_down_conn:shutdown(Pid)
      end, ok, Ds),
    %% upstreams will be killed by connection itself
    ok.
code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

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

%% Handle async connection ack
handle_connected(Pid, #state{pending_downstreams = Pending,
                             downstreams = Ds} = St) ->
    Pending1 = lists:delete(Pid, Pending),
    Downstreams1 = ds_add_downstream(Pid, Ds),
    St#state{pending_downstreams = Pending1,
             downstreams = Downstreams1}.

handle_get(Upstream, Opts, #state{downstreams = Ds,
                                  upstreams = Us} = St) ->
    {Downstream, N, Ds1} = ds_get(Ds),
    MonRef = erlang:monitor(process, Upstream),
    Us1 = Us#{Upstream => {Downstream, MonRef}},
    ok = mtp_down_conn:upstream_new(Downstream, Upstream, Opts),
    {Downstream, maybe_spawn_connection(
                   N,
                   St#state{downstreams = Ds1,
                            upstreams = Us1})}.

handle_return(Upstream, #state{downstreams = Ds,
                               upstreams = Us} = St) ->
    {{Downstream, MonRef}, Us1} = maps:take(Upstream, Us),
    ok = mtp_down_conn:upstream_closed(Downstream, Upstream),
    erlang:demonitor(MonRef, [flush]),
    Ds1 = ds_return(Downstream, Ds),
    St#state{downstreams = Ds1,
             upstreams = Us1}.

handle_down(MonRef, Pid, Reason, #state{downstreams = Ds,
                                        downstream_monitors = DsM,
                                        upstreams = Us,
                                        pending_downstreams = Pending} = St) ->
    case maps:take(Pid, Us) of
        {{Downstream, MonRef}, Us1} ->
            ok = mtp_down_conn:upstream_closed(Downstream, Pid),
            Ds1 = ds_return(Downstream, Ds),
            St#state{downstreams = Ds1,
                     upstreams = Us1};
        error ->
            case maps:take(MonRef, DsM) of
                {Pid, DsM1} ->
                    Pending1 = lists:delete(Pid, Pending),
                    Ds1 = ds_remove(Pid, Ds),
                    ?log(error, "Downstream=~p is down. reason=~p", [Pid, Reason]),
                    St#state{pending_downstreams = Pending1,
                             downstreams = Ds1,
                             downstream_monitors = DsM1};
                _ ->
                    ?log(error, "Unexpected DOWN. ref=~p, pid=~p, reason=~p", [MonRef, Pid, Reason]),
                    St
            end
    end.

maybe_spawn_connection(CurrentMin, #state{pending_downstreams = Pending} = St) ->
    %% if N > X and len(pending) < Y -> connect()
    %% TODO: shrinking (by timer)
    ToSpawn =
        case application:get_env(?APP, clients_per_dc_connection) of
            {ok, N} when CurrentMin > N,
                         Pending == [] ->
                2;
            {ok, N} when CurrentMin > (N * 1.5),
                         length(Pending) < ?BURST_MAX ->
                %% To survive initial bursts
                ?BURST_MAX - length(Pending);
            _ ->
                0
        end,
    connect_many(ToSpawn, St).

connect_many(ToSpawn, St) ->
    lists:foldl(
      fun(_, S) ->
              connect(S)
      end, St, lists:seq(1, ToSpawn)).

%% Initiate new async connection
connect(#state{pending_downstreams = Pending,
               downstream_monitors = DsM,
               dc_id = DcId} = St) ->
    Pid = do_connect(DcId),
    MonRef = erlang:monitor(process, Pid),
    St#state{pending_downstreams = [Pid | Pending],
             downstream_monitors = DsM#{MonRef => Pid}}.

%% Asynchronous connect
do_connect(DcId) ->
    {ok, Pid} = mtp_down_conn_sup:start_conn(self(), DcId),
    Pid.

%% Block until all async connections are acked
wait_pending(#state{pending_downstreams = Pending,
                    downstream_monitors = DsM} = St) ->
    lists:foldl(
      fun(Pid, #state{pending_downstreams = [Pid | Remaining],
                      downstreams = Ds} = St1) ->
              receive
                  {'$gen_cast', {connected, Pid}} -> Pid;
                  {'DOWN', MonitorRef, process, Pid, Reason} ->
                      %% maybe try to re-connect?
                      (maps:get(MonitorRef, DsM, undefined) == Pid)
                          orelse exit({unexpected_down,
                                       MonitorRef, Pid, Reason}),
                      exit({connection_failed, Pid, Reason})
              after 10000 ->
                      exit({timeout, receive Smth -> Smth after 0 -> none end})
              end,
              St1#state{pending_downstreams = Remaining,
                        downstreams = ds_add_downstream(Pid, Ds)}
      end, St, Pending).

%% New downstream connection storage
-spec ds_new([downstream()]) -> ds_store().
ds_new(Connections) ->
    Psq = pid_psq:new(),
    %% TODO: add `from_list` function
    lists:foldl(
      fun(Conn, Psq1) ->
              pid_psq:add(Conn, Psq1)
      end, Psq, Connections).

-spec ds_fold(fun( (downstream(), integer(), Acc) -> Acc ), Acc, ds_store()) -> Acc when
      Acc :: any().
ds_fold(Fun, Acc0, St) ->
    psq:fold(
      fun(_, N, Pid, Acc) ->
              Fun(Pid, N, Acc)
      end, Acc0, St).

%% Add new downstream to storage
-spec ds_add_downstream(downstream(), ds_store()) -> ds_store().
ds_add_downstream(Conn, St) ->
    pid_psq:add(Conn, St).

%% Get least loaded downstream connection
-spec ds_get(ds_store()) -> {downstream(), pos_integer(), ds_store()}.
ds_get(St) ->
    %% TODO: should return real number of connections
    {ok, {{Conn, N}, St1}} = pid_psq:get_min_priority(St),
    {Conn, N, St1}.

%% Return connection back to storage
-spec ds_return(downstream(), ds_store()) -> ds_store().
ds_return(Pid, St) ->
    %% It may return 'undefined' if down_conn crashed
    case pid_psq:dec_priority(Pid, St) of
        {ok, St1} ->
            St1;
        undefined ->
            ?log(warning, "Attempt to release unknown connection ~p", [Pid]),
            St
    end.

-spec ds_remove(downstream(), ds_store()) -> ds_store().
ds_remove(Downstream, St) ->
    pid_psq:delete(Downstream, St).
