%%
%% eredis_client
%%
%% The client is implemented as a gen_server which keeps one socket
%% open to a single Redis instance. Users call us using the API in
%% eredis.erl.
%%
%% The client works like this:
%%  * When starting up, we connect to Redis with the given connection
%%     information, or fail.
%%  * Users calls us using gen_server:call, we send the request to Redis,
%%    add the calling process at the end of the queue and reply with
%%    noreply. We are then free to handle new requests and may reply to
%%    the user later.
%%  * We receive data on the socket, we parse the response and reply to
%%    the client at the front of the queue. If the parser does not have
%%    enough data to parse the complete response, we will wait for more
%%    data to arrive.
%%  * For pipeline commands, we include the number of responses we are
%%    waiting for in each element of the queue. Responses are queued until
%%    we have all the responses we need and then reply with all of them.
%%
%% @private
-module(eredis_client).
-behaviour(gen_server).
-include("eredis.hrl").
-include_lib("kernel/include/logger.hrl").

-define(CONNECT_TIMEOUT, 5000).
-define(RECONNECT_SLEEP, 100).

%% API
-export([start_link/1, stop/1]).

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

%% Used by eredis_sub_client.erl
-export([read_database/1, get_auth_command/2, connect/8, get_active/2]).

-record(state, {
                host            :: string() | {local, string()} | undefined,
                port            :: integer() | undefined,
                auth_cmd        :: obfuscated() | undefined,
                database        :: binary() | undefined,
                reconnect_sleep :: reconnect_sleep() | undefined,
                connect_timeout :: integer() | undefined,
                socket_options  :: list(),
                tls_options     :: list(),
                sentinel        :: list() | undefined,

                transport       :: gen_tcp | ssl,
                active          :: pos_integer() | true,
                socket          :: gen_tcp:socket() | ssl:sslsocket() | undefined,
                reconnect_timer :: reference() | undefined,
                parser_state    :: #pstate{} | undefined,
                queue           :: eredis_queue() | undefined
               }).

%%
%% API
%%

-spec start_link(Options::options()) ->
          {ok, Pid::pid()} | {error, Reason::term()}.
start_link(Options) ->
    case proplists:lookup(name, Options) of
        {name, Name} ->
            gen_server:start_link(Name, ?MODULE, Options, []);
        none ->
            gen_server:start_link(?MODULE, Options, [])
    end.


stop(Pid) ->
    gen_server:call(Pid, stop).

%%====================================================================
%% gen_server callbacks
%%====================================================================

init(Options) ->
    Host           = proplists:get_value(host, Options, "localhost"),
    Port           = proplists:get_value(port, Options, 6379),
    Database       = proplists:get_value(database, Options, 0),
    Username       = proplists:get_value(username, Options, undefined),
    Password       = proplists:get_value(password, Options, undefined),
    ReconnectSleep = proplists:get_value(reconnect_sleep, Options, ?RECONNECT_SLEEP),
    ConnectTimeout = proplists:get_value(connect_timeout, Options, ?CONNECT_TIMEOUT),
    SocketOptions  = proplists:get_value(socket_options, Options, []),
    TlsOptions     = proplists:get_value(tls, Options, []),
    Transport      = transport_module(TlsOptions),
    Active         = get_active(SocketOptions, TlsOptions),
    Sentinel       = proplists:get_value(sentinel, Options, undefined),

    %% We can handle {active, N} and {active, true}. Other modes crash here.
    true = is_integer(Active) orelse Active =:= true,

    State = #state{host = Host,
                   port = Port,
                   database = read_database(Database),
                   auth_cmd = get_auth_command(Username, Password),
                   reconnect_sleep = ReconnectSleep,
                   connect_timeout = ConnectTimeout,
                   socket_options = SocketOptions,
                   tls_options = TlsOptions,
                   transport = Transport,
                   active = Active,
                   sentinel = Sentinel,
                   socket = undefined,
                   parser_state = eredis_parser:init(),
                   queue = queue:new()},

    case ReconnectSleep of
        no_reconnect ->
            case connect(State) of
                {ok, _NewState} = Res -> Res;
                {error, Reason} -> {stop, Reason}
            end;
        T when is_integer(T) ->
            self() ! initiate_connection,
            {ok, State}
    end.

handle_call({request, Req}, From, State) ->
    do_request(Req, From, State);

handle_call({pipeline, Pipeline}, From, State) ->
    do_pipeline(Pipeline, From, State);

handle_call(stop, _From, State) ->
    {stop, normal, ok, State};

handle_call(_Request, _From, State) ->
    {reply, unknown_request, State}.


handle_cast({request, Req}, State) ->
    case do_request(Req, undefined, State) of
        {reply, _Reply, State1} ->
            {noreply, State1};
        {noreply, State1} ->
            {noreply, State1}
    end;

handle_cast({request, Req, Pid}, State) ->
    case do_request(Req, Pid, State) of
        {reply, Reply, State1} ->
            safe_send(Pid, {response, Reply}),
            {noreply, State1};
        {noreply, State1} ->
            {noreply, State1}
    end;

handle_cast(_Msg, State) ->
    {noreply, State}.


%% Receive TCP/TLS data from socket. Match `Socket' to enforce sanity.
handle_info({Type, Socket, Data}, #state{socket = Socket} = State)
  when Type =:= tcp orelse Type =:= ssl ->
    {noreply, handle_response(Data, State)};

%% Socket switched to passive mode due to {active, N}.
handle_info({Passive, Socket},
            #state{socket = Socket, transport = Transport, active = N} = State)
  when Passive =:= tcp_passive; Passive =:= ssl_passive ->
    case setopts(Socket, Transport, [{active, N}]) of
        ok ->
            {noreply, State};
        {error, Reason} ->
            maybe_reconnect(Reason, State)
    end;

%% Socket errors. If the network or peer is down, the error is not
%% always followed by a tcp_closed.
%%
%% TLS 1.3: Called after a connect when the client certificate has expired
handle_info({Error, Socket, Reason}, #state{socket = Socket} = State)
  when Error =:= tcp_error; Error =:= ssl_error ->
    maybe_reconnect(Reason, State);

%% Socket got closed, for example by Redis terminating idle
%% clients. If desired, spawn of a new process which will try to reconnect and
%% notify us when Redis is ready. In the meantime, we can respond with
%% an error message to all our clients.
%%
%% fake_socket is used for testing.
handle_info({Closed, Socket}, #state{socket = OurSocket} = State)
  when Closed =:= tcp_closed orelse Closed =:= ssl_closed,
       Socket =:= OurSocket orelse Socket =:= fake_socket ->
    maybe_reconnect(Closed, State#state{socket = undefined});

%% Ignore messages and errors for an old socket.
handle_info({Type, Socket, _}, #state{socket = OurSocket} = State)
  when OurSocket =/= Socket,
       Type =:= tcp       orelse Type =:= ssl       orelse
       Type =:= tcp_error orelse Type =:= ssl_error ->
    %% Ignore tcp messages and errors when the socket in message
    %% doesn't match our state.
    {noreply, State};

%% Ignore close for an old socket.
handle_info({Type, Socket}, #state{socket = OurSocket} = State)
  when OurSocket =/= Socket,
       Type =:= tcp_closed orelse Type =:= ssl_closed ->
    {noreply, State};

%% Errors returned by gen_tcp:send/2 and ssl:send/2 are handled
%% asynchronously by message passing to self.
handle_info({send_error, Socket, Reason}, #state{socket = Socket} = State) ->
    maybe_reconnect(Reason, State);

handle_info({send_error, _Socket, _Reason}, State) ->
    %% Socket doesn't match the state. Ignore.
    {noreply, State};

%% Redis is ready to accept requests, the given Socket is a socket
%% already connected and authenticated.
handle_info({connection_ready, Socket}, #state{socket = undefined} = State) ->
    {noreply, State#state{socket = Socket}};

%% eredis can be used in Poolboy, but it requires to support a simple API
%% that Poolboy uses to manage the connections.
handle_info(stop, State) ->
    {stop, shutdown, State};

handle_info(initiate_connection, #state{socket = undefined} = State) ->
    case connect(State) of
        {ok, NewState} ->
            {noreply, NewState};
        {error, Reason} ->
            ?LOG_WARNING("eredis: Initial connect failed: ~p", [Reason]),
            {noreply, schedule_reconnect(State)}
    end;

handle_info(reconnect, #state{socket = undefined} = State) ->
    %% Scheduled reconnect, if disconnected.
    maybe_reconnect(retry, State#state{reconnect_timer = undefined});
handle_info(reconnect, State) ->
    %% Already connected.
    {noreply, State#state{reconnect_timer = undefined}};

handle_info(_Info, State) ->
    {noreply, State}.

terminate(_Reason, #state{socket = undefined}) ->
    ok;
terminate(_Reason, #state{socket = Socket, transport = Transport}) ->
    Transport:close(Socket).

%% Code change succeeds only if the state record has not changed.
code_change(_OldVsn, #state{} = State, _Extra) ->
    {ok, State};
code_change(_OldVsn, _State, _Extra) ->
    {error, not_implemented}.

%%--------------------------------------------------------------------
%%% Internal functions
%%--------------------------------------------------------------------

-spec do_request(Req::iolist(), From::pid() | undefined, #state{}) ->
          {noreply, #state{}} | {reply, Reply::any(), #state{}}.
%% @doc: Sends the given request to redis. If we do not have a
%% connection, returns error.
do_request(_Req, _From, #state{socket = undefined} = State) ->
    {reply, {error, no_connection}, State};

do_request(Req, From, #state{socket=Socket, transport=Transport}=State) ->
    case Transport:send(Socket, Req) of
        ok ->
            NewQueue = queue:in({1, From}, State#state.queue),
            {noreply, State#state{queue = NewQueue}};
        {error, Reason} ->
            self() ! {send_error, Socket, Reason},
            {reply, {error, Reason}, State}
    end.

-spec do_pipeline(Pipeline::pipeline(), From::pid(), #state{}) ->
          {noreply, #state{}} | {reply, Reply::any(), #state{}}.
%% @doc: Sends the entire pipeline to redis. If we do not have a
%% connection, returns error.
do_pipeline(_Pipeline, _From, #state{socket = undefined} = State) ->
    {reply, {error, no_connection}, State};

do_pipeline(Pipeline, From, #state{socket=Socket, transport=Transport}=State) ->
    case Transport:send(Socket, Pipeline) of
        ok ->
            NewQueue = queue:in({length(Pipeline), From, []}, State#state.queue),
            {noreply, State#state{queue = NewQueue}};
        {error, Reason} ->
            self() ! {send_error, Socket, Reason},
            {reply, {error, Reason}, State}
    end.

-spec handle_response(Data::binary(), State::#state{}) -> NewState::#state{}.
%% @doc: Handle the response coming from Redis. This includes parsing
%% and replying to the correct client, handling partial responses,
%% handling too much data and handling continuations.
handle_response(Data, #state{parser_state = ParserState,
                             queue = Queue} = State) ->

    case eredis_parser:parse(ParserState, Data) of
        %% Got complete response, return value to client
        {ReturnCode, Value, NewParserState} ->
            NewQueue = reply({ReturnCode, Value}, Queue),
            State#state{parser_state = NewParserState,
                        queue = NewQueue};

        %% Got complete response, with extra data, reply to client and
        %% recurse over the extra data
        {ReturnCode, Value, Rest, NewParserState} ->
            NewQueue = reply({ReturnCode, Value}, Queue),
            handle_response(Rest, State#state{parser_state = NewParserState,
                                              queue = NewQueue});

        %% Parser needs more data, the parser state now contains the
        %% continuation data and we will try calling parse again when
        %% we have more data
        {continue, NewParserState} ->
            State#state{parser_state = NewParserState}
    end.

%% @doc: Sends a value to the first client in queue. Returns the new
%% queue without this client. If we are still waiting for parts of a
%% pipelined request, push the reply to the the head of the queue and
%% wait for another reply from redis.
reply(Value, Queue) ->
    case queue:out(Queue) of
        {{value, {1, From}}, NewQueue} ->
            safe_reply(From, Value),
            NewQueue;
        {{value, {1, From, Replies}}, NewQueue} ->
            safe_reply(From, lists:reverse([Value | Replies])),
            NewQueue;
        {{value, {N, From, Replies}}, NewQueue} when N > 1 ->
            queue:in_r({N - 1, From, [Value | Replies]}, NewQueue);
        {empty, Queue} ->
            %% Oops
            ?LOG_NOTICE("eredis: Nothing in queue, but got value from parser"),
            exit(empty_queue)
    end.

%% @doc Send `Value' to each client in queue. Only useful for sending
%% an error message. Any in-progress reply data is ignored.
-spec reply_all(any(), eredis_queue()) -> ok.
reply_all(Value, Queue) ->
    case queue:peek(Queue) of
        empty ->
            ok;
        {value, Item} ->
            safe_reply(recipient(Item), Value),
            reply_all(Value, queue:drop(Queue))
    end.

recipient({_, From}) ->
    From;
recipient({_, From, _}) ->
    From.

safe_reply(undefined, _Value) ->
    ok;
safe_reply(Pid, Value) when is_pid(Pid) ->
    safe_send(Pid, {response, Value});
safe_reply(From, Value) ->
    gen_server:reply(From, Value).

safe_send(Pid, Value) ->
    try erlang:send(Pid, Value)
    catch
        Err:Reason ->
            ?LOG_NOTICE("eredis: Failed to send message to ~p with reason ~p",
                        [Pid, {Err, Reason}])
    end.

%% @doc: Helper for connecting to Redis, authenticating and selecting
%% the correct database synchronously. On successful connect, a reconnect
%% is scheduled, just in case the connection breaks immediately afterwards,
%% so we don't reconnect until reconnect_sleep milliseconds has elapsed.
%% Returns: {ok, State} or {error, Reason}.
connect(#state{host = Host0,
               port = Port0,
               socket_options = SocketOptions,
               tls_options = TlsOptions,
               connect_timeout = ConnectTimeout,
               auth_cmd = AuthCmd,
               database = Db,
               active = Active,
               sentinel = SentinelOptions} = State) ->
    Endpoint = case SentinelOptions of
                   undefined -> {Host0, Port0};
                   _ ->
                       MasterGroup = proplists:get_value(master_group, SentinelOptions, mymaster),
                       case whereis(MasterGroup) of
                           undefined -> eredis_sentinel:start_link(MasterGroup, SentinelOptions);
                           _ -> ok
                       end,
                       case eredis_sentinel:get_master(MasterGroup) of
                           {ok, Host1, Port1} -> {Host1, Port1};
                           SentinelError -> SentinelError
                       end
               end,
    case Endpoint of
        {error, _} = Err -> Err;
        {Host, Port} ->
            case connect(Host, Port, SocketOptions, TlsOptions,
                         ConnectTimeout, AuthCmd, Db, Active) of
                {ok, Socket} ->
                    %% In case the connection terminates immediately (this happens with
                    %% an expired certificate with TLS 1.3) schedule a reconnect already
                    %% so that we don't try to reconnect if an error is received before
                    %% reconnect_sleep milliseconds has elapsed.
                    {ok, schedule_reconnect(State#state{socket = Socket})};
                Error ->
                    Error
            end
    end.

%% Connect helper also used by eredis_sub_client.
-spec connect(Host           :: string() | {local, string()} | undefined,
              Port           :: integer() | undefined,
              SocketOptions  :: list(),
              TlsOptions     :: list(),
              ConnectTimeout :: integer() | undefined,
              AuthCmd        :: obfuscated() | undefined,
              Db             :: binary() | undefined,
              Active         :: pos_integer() | true) ->
          {ok, Socket :: gen_tcp:socket() | ssl:sslsocket()} |
          {error, Reason :: term()}.
connect(Host, Port, SocketOptions, TlsOptions, ConnectTimeout, AuthCmd, Db,
        Active) ->
    {ok, {AFamily, Addrs}} = get_addrs(Host),
    Port1 = case AFamily of
                local -> 0;
                _ -> Port
            end,
    %% Connect in passive mode. We switch to active N or true later.
    SocketOptions1 = lists:keydelete(active, 1, SocketOptions),
    SocketOptions2 = lists:ukeymerge(1, lists:keysort(1, SocketOptions1),
                                     lists:keysort(1, ?SOCKET_OPTS)),
    SocketOptions3 = [AFamily, ?SOCKET_MODE, {active, false} | SocketOptions2],

    TlsOptions1 =
        case TlsOptions of
            [] -> [];
            [_|_] -> [{active, false} | lists:keydelete(active, 1, TlsOptions)]
        end,

    connect_next_addr(Addrs, Port1, SocketOptions3, TlsOptions1,
                      ConnectTimeout, AuthCmd, Db, Active).

connect_next_addr([Addr|Addrs], Port, SocketOptions, TlsOptions, ConnectTimeout,
                  AuthCmd, Db, Active) ->
    case gen_tcp:connect(Addr, Port, SocketOptions, ConnectTimeout) of
        {ok, Socket} ->
            case maybe_upgrade_to_tls(Socket, TlsOptions, ConnectTimeout) of
                {ok, NewSocket} ->
                    Transport = transport_module(TlsOptions),
                    case authenticate(NewSocket, Transport, AuthCmd) of
                        ok ->
                            case select_database(NewSocket, Transport, Db) of
                                ok ->
                                    case setopts(NewSocket, Transport,
                                                 [{active, Active}]) of
                                        ok ->
                                            {ok, NewSocket};
                                        {error, Reason} ->
                                            Transport:close(NewSocket),
                                            {error, {setopts, Reason}}
                                    end;
                                {error, Reason} ->
                                    Transport:close(NewSocket),
                                    {error, {select_error, Reason}}
                            end;
                        {error, Reason} ->
                            Transport:close(NewSocket),
                            {error, {authentication_error, Reason}}
                    end;
                {error, Reason} ->
                    gen_tcp:close(Socket),
                    {error, {failed_to_upgrade_to_tls, Reason}} %% Used in TLS v1.2
            end;
        {error, Reason} when Addrs =:= [] ->
            {error, {connection_error, Reason}};
        {error, _Reason} ->
            %% Try next address
            connect_next_addr(Addrs, Port, SocketOptions, TlsOptions,
                              ConnectTimeout, AuthCmd, Db, Active)
    end.

maybe_upgrade_to_tls(Socket, [], _Timeout) ->
    {ok, Socket};
maybe_upgrade_to_tls(Socket, TlsOptions, Timeout) ->
    %% Active needs to be 'false' before an upgrade to ssl is possible
    ssl:connect(Socket, TlsOptions, Timeout).

get_addrs({local, Path}) ->
    {ok, {local, [{local, Path}]}};
get_addrs(Hostname) ->
    case inet:parse_address(Hostname) of
        {ok, {_, _, _, _} = Addr} ->             {ok, {inet, [Addr]}};
        {ok, {_, _, _, _, _, _, _, _} = Addr} -> {ok, {inet6, [Addr]}};
        {error, einval} ->
            case inet:getaddrs(Hostname, inet6) of
                {error, _} ->
                    case inet:getaddrs(Hostname, inet) of
                        {ok, Addrs} ->
                            {ok, {inet, deduplicate(Addrs)}};
                        {error, _} = Res ->
                            Res
                    end;
                {ok, Addrs} ->
                    {ok, {inet6, deduplicate(Addrs)}}
            end
    end.

%% Removes duplicates without sorting.
deduplicate([X|Xs]) ->
    [X | deduplicate([Y || Y <- Xs,
                           Y =/= X])];
deduplicate([]) ->
    [].

select_database(_Socket, _TransportType, undefined) ->
    ok;
select_database(_Socket, _TransportType, <<"0">>) ->
    ok;
select_database(Socket, TransportType, Database) ->
    do_sync_command(Socket, TransportType, ["SELECT", " ", Database, "\r\n"]).

authenticate(_Socket, _TransportType, undefined) ->
    ok;
authenticate(Socket, TransportType, AuthCmd) when is_function(AuthCmd, 0) ->
    do_sync_command(Socket, TransportType, AuthCmd()).

%% @doc: Executes the given command synchronously, expects Redis to
%% return "+OK\r\n", otherwise it will fail.
%% The socket must be in passive mode when calling this function.
do_sync_command(Socket, Transport, Command) ->
    case Transport:send(Socket, Command) of
        ok ->
            case Transport:recv(Socket, 0, ?RECV_TIMEOUT) of
                {ok, <<"+OK\r\n">>} ->
                    ok;
                {ok, Data} ->
                    {error, {unexpected_response, Data}};
                {error, Reason} ->
                    {error, Reason}
            end;
        {error, Reason} ->
            {error, Reason}
    end.

transport_module([]) -> gen_tcp;
transport_module(_) -> ssl.

get_active(SocketOptions, []) ->
    proplists:get_value(active, SocketOptions, ?SOCKET_ACTIVE);
get_active(_SocketOptions, TlsOptions) ->
    proplists:get_value(active, TlsOptions, ?SOCKET_ACTIVE).

setopts(Socket, _Transport=gen_tcp, Opts) -> inet:setopts(Socket, Opts);
setopts(Socket, _Transport=ssl, Opts)     ->  ssl:setopts(Socket, Opts).

close_socket(#state{socket = undefined} = State) ->
    State;
close_socket(#state{socket = Socket, transport = Transport} = State) ->
    Transport:close(Socket),
    State#state{socket = undefined}.

%% @doc Schedules a reconnect attempt, if reconnect is enabled.
-spec schedule_reconnect(#state{}) -> #state{}.
schedule_reconnect(#state{reconnect_sleep = no_reconnect} = State) ->
    State;
schedule_reconnect(#state{reconnect_sleep = ReconnectSleep,
                          reconnect_timer = undefined} = State) ->
    TRef = erlang:send_after(ReconnectSleep, self(), reconnect),
    State#state{reconnect_timer = TRef}.

%% @doc Logs a lost connection error with Reason and potentially reconnects or
%% schedules a reconnect, depending on options. The socket in the state is
%% closed, if any. Returns {noreply, State} or {stop, ExitReason, State} like
%% handle_info.
maybe_reconnect(Reason, #state{reconnect_sleep = no_reconnect, queue = Queue} = State) ->
    reply_all({error, Reason}, Queue),
    %% If we aren't going to reconnect, then there is nothing else for
    %% this process to do.
    {stop, normal, close_socket(State)};
maybe_reconnect(Reason, #state{queue = Queue, reconnect_timer = TRef} = State)
  when is_reference(TRef) ->
    %% Reconnect already scheduled.
    reply_all({error, Reason}, Queue),
    {noreply, close_socket(State#state{queue = queue:new()})};
maybe_reconnect(Reason,
                #state{queue = Queue,
                       host = Host,
                       port = Port,
                       reconnect_timer = undefined} = State) ->
    log_reconnect(Reason, Host, Port),

    %% Tell all of our clients what has happened.
    reply_all({error, Reason}, Queue),

    %% Throw away the socket and the queue, as we will never get a
    %% response to the requests sent on the old socket. The absence of
    %% a socket is used to signal we are "down"
    State1 = close_socket(State#state{queue = queue:new()}),
    case connect(State1) of
        {ok, State2} ->
            {noreply, State2};
        {error, NewReason} ->
            ?LOG_NOTICE("eredis: Reconnect failed: ~p", [NewReason]),
            {noreply, schedule_reconnect(State1)}
    end.

log_reconnect(retry, _Host, _Port) ->
    %% Don't flood the log for every retry, in case the Redis node is down.
    ok;
log_reconnect(Reason, Host, Port) ->
    ?LOG_WARNING("eredis: Re-establishing connection to ~p:~p due to ~p",
                 [Host, Port, Reason]).

read_database(undefined) ->
    undefined;
read_database(Database) when is_integer(Database) ->
    list_to_binary(integer_to_list(Database)).

-spec get_auth_command(Username :: iodata() | obfuscated() | undefined,
                       Password :: iodata() | obfuscated() | undefined) ->
          obfuscated() | undefined.
get_auth_command(Username, Password) ->
    case {deobfuscate(Username), deobfuscate(Password)} of
        {undefined, undefined} ->
            undefined;
        {undefined, ""} ->                      % legacy
            undefined;
        {undefined, Pass} ->
            AuthCmd = eredis:create_multibulk([<<"AUTH">>, Pass]),
            fun () -> AuthCmd end;
        {User, Pass} ->
            AuthCmd = eredis:create_multibulk([<<"AUTH">>, User, Pass]),
            fun () -> AuthCmd end
    end.

-spec deobfuscate(iodata() | fun(() -> iodata()) | undefined) ->
          iodata() | undefined.
deobfuscate(undefined) ->
    undefined;
deobfuscate(String) when is_list(String); is_binary(String) ->
    String;
deobfuscate(Fun) when is_function(Fun, 0) ->
    Fun().
