%% This Source Code Form is subject to the terms of the Mozilla Public
%% License, v. 2.0. If a copy of the MPL was not distributed with this
%% file, You can obtain one at https://mozilla.org/MPL/2.0/.
%%
%% Copyright (c) 2007-2023 Broadcom. All Rights Reserved. The term Broadcom refers to Broadcom Inc. and/or its subsidiaries.
%%

-module(osiris_replica).

-behaviour(gen_server).
-behaviour(osiris_member).

-include("osiris.hrl").

-define(DEFAULT_PORT_RANGE, {6000, 6500}).

%% osiris replica, starts TCP listener ("server side" of the link),
%% spawns remote reader, TCP listener replicates and
%% confirms latest offset back to primary

%% API functions
-export([start/2,
         start_link/1
        ]).
%% Test
-export([get_port/1, combine_ips_hosts/4]).
%% gen_server callbacks
-export([init/1,
         handle_continue/2,
         handle_call/3,
         handle_cast/2,
         handle_info/2,
         terminate/2,
         code_change/3,
         format_status/1]).

%% holds static or rarely changing fields
-record(cfg,
        {name :: osiris:name(),
         leader_pid :: pid(),
         acceptor_pid :: pid(),
         replica_reader_pid :: pid(),
         directory :: file:filename_all(),
         port :: non_neg_integer(),
         transport :: osiris_log:transport(),
         socket :: undefined | gen_tcp:socket() | ssl:sslsocket(),
         gc_interval :: infinity | non_neg_integer(),
         reference :: term(),
         event_formatter :: undefined | mfa(),
         counter :: counters:counters_ref(),
         token :: undefined | binary()}).

-type parse_state() ::
    undefined |
    binary() |
    {non_neg_integer(), iolist(), non_neg_integer()}.

-record(?MODULE,
        {cfg :: #cfg{},
         parse_state :: parse_state(),
         log :: osiris_log:state(),
         committed_chunk_id = -1 :: -1 | osiris:offset(),
         offset_listeners = [] ::
             [{pid(), osiris:offset(), mfa() | undefined}]
        }).

-opaque state() :: #?MODULE{}.

-export_type([state/0]).

-define(C_COMMITTED_OFFSET, ?C_NUM_LOG_FIELDS + 1).
-define(C_FORCED_GCS, ?C_NUM_LOG_FIELDS + 2).
-define(C_PACKETS, ?C_NUM_LOG_FIELDS + 3).
-define(C_READERS, ?C_NUM_LOG_FIELDS + 4).
-define(C_EPOCH, ?C_NUM_LOG_FIELDS + 5).
-define(ADD_COUNTER_FIELDS,
        [{committed_offset, ?C_COMMITTED_OFFSET, counter, "Last committed offset"},
         {forced_gcs, ?C_FORCED_GCS, counter, "Number of garbage collection runs"},
         {packets, ?C_PACKETS, counter, "Number of packets"},
         {readers, ?C_READERS, counter, "Number of readers"},
         {epoch, ?C_EPOCH, counter, "Current epoch"}]).

-define(DEFAULT_ONE_TIME_TOKEN_TIMEOUT, 30000).
-define(TOKEN_SIZE, 32).
-define(DEF_REC_BUF, 408300 * 5).

%%%===================================================================
%%% API functions
%%%===================================================================

-spec start(node(), Config :: osiris:config()) ->
    supervisor:startchild_ret().
start(Node, Config = #{name := Name}) when ?IS_STRING(Name) ->
    case supervisor:start_child({?SUP, Node},
                                #{id => Name,
                                  start => {?MODULE, start_link, [Config]},
                                  restart => temporary,
                                  shutdown => 5000,
                                  type => worker,
                                  modules => [?MODULE]}) of
        {ok, Pid} = Res ->
            %% make a dummy call to block until intialisation is complete
            _ = await(Pid),
            Res;
        {ok, _, Pid} = Res ->
            _ = await(Pid),
            Res;
        Err ->
            Err
    end.

%%--------------------------------------------------------------------
%% @doc
%% Starts the server
%%
%% @spec start_link() -> {ok, Pid} | ignore | {error, Error}
%% @end
%%--------------------------------------------------------------------
start_link(Conf) ->
    gen_server:start_link(?MODULE, Conf, []).

get_port(Server) ->
    gen_server:call(Server, get_port).

await(Server) ->
    gen_server:call(Server, await, infinity).

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

%%--------------------------------------------------------------------
%% @private
%% @doc
%% Initializes the server
%%
%% @spec init(Args) -> {ok, State} |
%%                     {ok, State, Timeout} |
%%                     ignore |
%%                     {stop, Reason}
%% @end
%%--------------------------------------------------------------------
init(Config) ->
    {ok, undefined, {continue, Config}}.

handle_continue(#{name := Name0,
                  epoch := Epoch,
                  leader_pid := LeaderPid,
                  reference := ExtRef} = Config, undefined)
  when ?IS_STRING(Name0) ->
    Name = osiris_util:normalise_name(Name0),
    process_flag(trap_exit, true),
    process_flag(message_queue_data, off_heap),
    Node = node(LeaderPid),

    %% osiris_writer:overview/1 may need to call into the writer process and
    %% wait for it to initialise before replying. This could take some time
    %% so it makes no sense to have an upper bound on the timeout
    case rpc:call(Node, osiris_writer, overview, [LeaderPid], infinity) of
        {error, no_process} ->
            ?INFO_(Name, "Writer process not alive, exiting...", []),
            {stop, {shutdown, writer_unavailable}, undefined};
        missing_file ->
            ?INFO_(Name, "missing file returned from writer, exiting...", []),
            {stop, {shutdown, missing_file}, undefined};
        {error, _} = Err ->
            {stop, Err, undefined};
        {badrpc, {'EXIT', shutdown}} ->
            ?INFO_(Name, "Writer process shutting down, exiting...", []),
            {stop, {shutdown, writer_unavailable}, undefined};
        {badrpc, nodedown} ->
            ?INFO_(Name, "Writer process node is down, exiting...", []),
            {stop, {shutdown, writer_unavailable}, undefined};
        {badrpc, Reason} ->
            {stop, {badrpc, Reason}, undefined};
        {ok, {LeaderRange, LeaderEpochOffs}} ->
            {Min, Max} = application:get_env(osiris, port_range,
                                             ?DEFAULT_PORT_RANGE),
            Transport = application:get_env(osiris, replication_transport, tcp),
            Self = self(),
            CntName = {?MODULE, ExtRef},

            Dir = osiris_log:directory(Config),
            Log = osiris_log:init_acceptor(LeaderRange, LeaderEpochOffs,
                                           Config#{dir => Dir,
                                                   counter_spec =>
                                                   {CntName, ?ADD_COUNTER_FIELDS}}),
            CntRef = osiris_log:counters_ref(Log),
            {NextOffset, LastChunk} = TailInfo = osiris_log:tail_info(Log),

            case LastChunk of
                empty ->
                    ok;
                {_, LastChId, LastTs} ->
                    %% need to ack last chunk back to leader so that it can
                    %% re-discover the committed offset
                    osiris_writer:ack(LeaderPid, {LastChId, LastTs})
            end,
            ?INFO_(Name, "osiris replica starting in epoch ~b, next offset ~b, tail info ~w",
                   [Epoch, NextOffset, TailInfo]),

            %% HostName: append the HostName to the Ip(s) list: in some cases
            %% like NAT or redirect the local ip addresses are not enough.
            %% ex: In docker with host network configuration the `inet:getaddrs`
            %% are only the IP(s) inside docker but the dns lookup happens
            %% outside the docker image (host machine).
            %% The host name is the last to leave the compatibility.
            %% See: rabbitmq/rabbitmq-server#3510
            {ok, HostName} = inet:gethostname(),

            %% Ips: are the first values used to connect the
            %% replicas
            {ok, Ips} = inet:getaddrs(HostName, inet),

            %% HostNameFromHost: The hostname value from RABBITMQ_NODENAME
            %% can be different from the machine hostname.
            %% In case of docker with bridge and extra_hosts the use case can be:
            %% RABBITMQ_NODENAME=rabbit@my-domain
            %% docker hostname = "114f4317c264"
            %% the HostNameFromHost will be "my-domain".
            %% btw 99% of the time the HostNameFromHost is equal to HostName.
            %% see: rabbitmq/osiris/issues/53 for more details
            HostNameFromHost = osiris_util:hostname_from_node(),

            IpsHosts = combine_ips_hosts(Transport, Ips, HostName,
                                         HostNameFromHost),

            Token = crypto:strong_rand_bytes(?TOKEN_SIZE),
            ?DEBUG_(Name, "replica resolved host endpoints: ~0p", [IpsHosts]),
            {Port, LSock} = open_listener(Transport, {Min, Max}, 0),
            ?DEBUG_(Name, "replica listening on port '~b' using transport ~s",
                    [Port, Transport]),
            Acceptor = spawn_link(fun() -> accept(Name, Transport, LSock, Self) end),

            ReplicaReaderConf = #{hosts => IpsHosts,
                                  port => Port,
                                  transport => Transport,
                                  name => Name,
                                  replica_pid => self(),
                                  leader_pid => LeaderPid,
                                  start_offset => TailInfo,
                                  reference => ExtRef,
                                  connection_token => Token},
            case osiris_replica_reader:start(Node, ReplicaReaderConf) of
                {ok, RRPid} ->
                    true = link(RRPid),
                    GcInterval0 = application:get_env(osiris,
                                                      replica_forced_gc_default_interval,
                                                      4999),

                    GcInterval1 = case is_integer(GcInterval0) of
                                      true ->
                                          _ = erlang:send_after(GcInterval0, self(), force_gc),
                                          GcInterval0;
                                      false ->
                                          infinity
                                  end,
                    counters:put(CntRef, ?C_COMMITTED_OFFSET, -1),
                    counters:put(CntRef, ?C_EPOCH, Epoch),
                    Shared = osiris_log:get_shared(Log),
                    osiris_util:cache_reader_context(self(), Dir, Name, Shared, ExtRef,
                                                     fun(Inc) ->
                                                             counters:add(CntRef, ?C_READERS, Inc)
                                                     end),
                    EvtFmt = maps:get(event_formatter, Config, undefined),
                    {noreply,
                     #?MODULE{cfg =
                                  #cfg{name = Name,
                                       leader_pid = LeaderPid,
                                       acceptor_pid = Acceptor,
                                       replica_reader_pid = RRPid,
                                       directory = Dir,
                                       port = Port,
                                       gc_interval = GcInterval1,
                                       reference = ExtRef,
                                       event_formatter = EvtFmt,
                                       counter = CntRef,
                                       token = Token,
                                       transport = Transport},
                              log = Log,
                              parse_state = undefined}};
                {error, Reason} ->
                    ?WARN_(Name, " failed to start replica reader. Reason ~0p", [Reason]),
                    {stop, {shutdown, Reason}, undefined}
            end
    end.

combine_ips_hosts(tcp, IPs, HostName, HostNameFromHost) when
      HostName =/= HostNameFromHost ->
    lists:append(IPs, [HostName, HostNameFromHost]);
combine_ips_hosts(tcp, IPs, HostName, _HostNameFromHost) ->
    lists:append(IPs, [HostName]);
combine_ips_hosts(ssl, IPs, HostName, HostNameFromHost) when
      HostName =/= HostNameFromHost ->
    lists:append([HostName, HostNameFromHost], IPs);
combine_ips_hosts(ssl, IPs, HostName, _HostNameFromHost) ->
    lists:append([HostName], IPs).

open_listener(_Transport, Range, 100) ->
    throw({stop, {no_available_ports_in_range, Range}});
open_listener(Transport, {Min, Max} = Range, Attempts) ->
    Offs = rand:uniform(Max - Min),
    Port = Min + Offs,
    Options = listener_opts(Transport),
    case listen(Transport, Port, Options) of
        {ok, LSock} ->
            {Port, LSock};
        {error, eaddrinuse} ->
            timer:sleep(Attempts),
            open_listener(Transport, Range, Attempts + 1);
        E ->
            throw({stop, E})
    end.

accept(Name, tcp, LSock, Process) ->
    case gen_tcp:accept(LSock) of
        {ok, Sock} ->
            try gen_tcp:close(LSock) of
                ok -> ok
            catch _:Err ->
                      ?DEBUG_(Name, "gen_tcp:close/1 failed with ~0p", [Err])
            end,
            ok = gen_tcp:controlling_process(Sock, Process),
            Process ! {socket, Sock},
            ok;
        {error, Err} ->
            ?DEBUG_(Name, "gen_tcp:accept/1 failed with ~0p", [Err]),
            gen_tcp:close(LSock),
            ok
    end;
accept(Name, ssl, LSock, Process) ->
    ?DEBUG_(Name, "Starting socket acceptor for replication over TLS", []),
    {ok, Sock0} = ssl:transport_accept(LSock),
    SslOptions = application:get_env(osiris, replication_server_ssl_options, []),
    case ssl:handshake(Sock0, SslOptions) of
        {ok, Sock} ->
            _ = ssl:close(LSock),
            ok = ssl:controlling_process(Sock, Process),
            Process ! {socket, Sock},
            ok;
        {error, {tls_alert, {handshake_failure, _}}} ->
            ?DEBUG_(Name, "Handshake failure, restarting listener...",
                    []),
            _ = spawn_link(fun() -> accept(Name, ssl, LSock, Process) end),
            ok;
        {error, E} ->
            ?DEBUG_(Name, "Error during handshake ~w", [E]);
        H ->
            ?DEBUG_(Name, "Unexpected result from TLS handshake ~w", [H])
    end,
    ok.

%%--------------------------------------------------------------------
%% @private
%% @doc
%% Handling call messages
%%
%% @spec handle_call(Request, From, State) ->
%%                                   {reply, Reply, State} |
%%                                   {reply, Reply, State, Timeout} |
%%                                   {noreply, State} |
%%                                   {noreply, State, Timeout} |
%%                                   {stop, Reason, Reply, State} |
%%                                   {stop, Reason, State}
%% @end
%%--------------------------------------------------------------------
handle_call(get_port, _From,
            #?MODULE{cfg = #cfg{port = Port}} = State) ->
    {reply, Port, State};
handle_call(get_reader_context, _From,
            #?MODULE{cfg =
                         #cfg{name = Name,
                              directory = Dir,
                              reference = Ref,
                              counter = CntRef},
                     committed_chunk_id = COffs,
                     log = Log} =
                State) ->
    Shared = osiris_log:get_shared(Log),
    Reply =
        #{dir => Dir,
          name => Name,
          committed_offset => COffs,
          shared => Shared,
          reference => Ref,
          readers_counter_fun => fun(Inc) -> counters:add(CntRef, ?C_READERS, Inc) end},
    {reply, Reply, State};
handle_call({update_retention, Retention}, _From,
            #?MODULE{log = Log0} = State) ->
    Log = osiris_log:update_retention(Retention, Log0),
    {reply, ok, State#?MODULE{log = Log}};
handle_call(await, _From, State) ->
    {reply, ok, State};
handle_call(Unknown, _From,
            #?MODULE{cfg = #cfg{name = Name}} = State) ->
    ?INFO_(Name, "unknown command ~W", [Unknown, 10]),
    {reply, {error, unknown_command}, State}.

%%--------------------------------------------------------------------
%% @private
%% @doc
%% Handling cast messages
%%
%% @spec handle_cast(Msg, State) -> {noreply, State} |
%%                                  {noreply, State, Timeout} |
%%                                  {stop, Reason, State}
%% @end
%%--------------------------------------------------------------------
handle_cast({committed_offset, CommittedChId},
            #?MODULE{cfg = #cfg{counter = Cnt},
                     log = Log,
                     committed_chunk_id = LastCommittedChId} =
                State) ->
    case CommittedChId > LastCommittedChId of
        true ->
            %% notify offset listeners
            counters:put(Cnt, ?C_COMMITTED_OFFSET, CommittedChId),
            ok = osiris_log:set_committed_chunk_id(Log, CommittedChId),
            {noreply,
             notify_offset_listeners(
               State#?MODULE{committed_chunk_id = CommittedChId})};
        false ->
            State
    end;
handle_cast({register_offset_listener, Pid, EvtFormatter, Offset},
            #?MODULE{cfg = #cfg{reference = Ref,
                                event_formatter = DefaultFmt},
                     log = Log,
                     offset_listeners = Listeners} = State) ->
    Max = max_readable_chunk_id(Log),
    case Offset =< Max of
        true ->
            %% only evaluate the request, the rest will be evaluated
            %% when data is written or committed
            Evt = wrap_osiris_event(
                    select_formatter(EvtFormatter, DefaultFmt),
                    {osiris_offset, Ref, Max}),
            Pid ! Evt,
            {noreply, State};
        false ->
            %% queue the offset listener for later
            {noreply,
             State#?MODULE{offset_listeners = [{Pid, Offset, EvtFormatter} |
                                               Listeners]}}
    end;
handle_cast(Msg, #?MODULE{cfg = #cfg{name = Name}} = State) ->
    ?DEBUG_(Name, "osiris_replica unhandled cast ~w", [Msg]),
    {noreply, State}.

%%--------------------------------------------------------------------
%% @private
%% @doc
%% Handling all non call/cast messages
%%
%% @spec handle_info(Info, State) -> {noreply, State} |
%%                                   {noreply, State, Timeout} |
%%                                   {stop, Reason, State}
%% @end
%%--------------------------------------------------------------------
handle_info(force_gc,
            #?MODULE{cfg = #cfg{gc_interval = Interval,
                                counter = Cnt}} =
                State) ->
    garbage_collect(),
    counters:add(Cnt, ?C_FORCED_GCS, 1),
    case is_integer(Interval) of
        true ->
            _ = erlang:send_after(Interval, self(), force_gc),
            ok;
        false ->
            ok
    end,
    {noreply, State};
handle_info({socket, Socket}, #?MODULE{cfg = #cfg{name = Name,
                                                  token = Token,
                                                  transport = Transport} = Cfg} = State) ->

    Timeout = application:get_env(osiris, one_time_token_timeout,
                                  ?DEFAULT_ONE_TIME_TOKEN_TIMEOUT),
    case recv(Transport, Socket, ?TOKEN_SIZE, Timeout) of
        {ok, Token} ->
            %% token validated, all good we can let the flood of data begin
            ok = setopts(Transport, Socket, [{active, 5}]),
            {noreply, State#?MODULE{cfg = Cfg#cfg{socket = Socket}}};
        {ok, Other} ->
            ?WARN_(Name, "invalid token received ~w expected ~w",
                   [Other, Token]),
            {stop, invalid_token, State};
        {error, Reason} ->
            ?WARN_(Name, "error awaiting token ~w", [Reason])
    end;
handle_info({tcp, Socket, Bin}, State) ->
    handle_incoming_data(Socket, Bin, State);
handle_info({ssl, Socket, Bin}, State) ->
    handle_incoming_data(Socket, Bin, State);
handle_info({tcp_passive, Socket},
            #?MODULE{cfg = #cfg{socket = Socket}} = State) ->
    %% we always top up before processing each packet so no need to do anything
    %% here
    {noreply, State};
handle_info({ssl_passive, Socket},
            #?MODULE{cfg = #cfg{socket = Socket}} = State) ->
    %% we always top up before processing each packet so no need to do anything
    %% here
    {noreply, State};
handle_info({tcp_closed, Socket},
            #?MODULE{cfg = #cfg{name = Name, socket = Socket}} = State) ->
    ?DEBUG_(Name, "Socket closed. Exiting...", []),
    {stop, normal, State};
handle_info({ssl_closed, Socket},
            #?MODULE{cfg = #cfg{name = Name, socket = Socket}} = State) ->
    ?DEBUG_(Name, "TLS socket closed. Exiting...", []),
    {stop, normal, State};
handle_info({tcp_error, Socket, Error},
            #?MODULE{cfg = #cfg{name = Name, socket = Socket}} = State) ->
    ?DEBUG_(Name, "osiris_replica: ~ts Socket error ~0p. Exiting...",
           [Error]),
    {stop, {tcp_error, Error}, State};
handle_info({ssl_error, Socket, Error},
            #?MODULE{cfg = #cfg{name = Name, socket = Socket}} = State) ->
    ?DEBUG_(Name, "TLS socket error ~w. Exiting...",
           [Error]),
    {stop, {ssl_error, Error}, State};
handle_info({'DOWN', _Ref, process, Pid, Info},
            #?MODULE{cfg = #cfg{name = Name}} = State) ->
    ?DEBUG_(Name, "DOWN received for Pid ~w, Info: ~w",
           [Pid, Info]),
    {noreply, State};
handle_info({'EXIT', RRPid, Info},
            #?MODULE{cfg = #cfg{name = Name,
                                replica_reader_pid = RRPid}} = State) ->
    %% any replica reader exit is troublesome and requires the replica to also
    %% terminate
    case lists:member(Info, [normal, shutdown]) of
        true ->
            ?DEBUG_(Name, "replica reader ~w exited with ~w", [RRPid, Info]);
        false ->
            ?ERROR_(Name, "replica reader ~w exited with ~w", [RRPid, Info])
    end,
    {stop, {shutdown, Info}, State};
handle_info({'EXIT', Ref, normal},
            #?MODULE{cfg = #cfg{name = Name}} = State) ->
    %% we assume any 'normal' EXIT is fine to ignore (port etc)
    ?DEBUG_(Name, "EXIT received for ~w with 'normal'", [Ref]),
    {noreply, State};
handle_info({'EXIT', Ref, Info},
            #?MODULE{cfg = #cfg{name = Name}} = State) ->
    ?WARN_(Name, "unexpected linked process or port ~w exited with ~w",
           [Ref, Info]),
    {stop, unexpected_exit, State}.

handle_incoming_data(Socket, Bin,
                     #?MODULE{cfg =
                              #cfg{socket = Socket,
                                   leader_pid = LeaderPid,
                                   transport = Transport,
                                   counter = Cnt},
                              parse_state = ParseState0,
                              log = Log0} =
                     State0) ->
    counters:add(Cnt, ?C_PACKETS, 1),
    %% deliberately ignoring return value here as it would fail if the
    %% tcp connection has been closed and we still want to try to process
    %% any messages still in the mailbox
    _ = setopts(Transport, Socket, [{active, 1}]),
    %% validate chunk
    {ParseState, OffsetChunks} = parse_chunk(Bin, ParseState0, []),
    {OffsetTimestamp, Log} =
        lists:foldl(fun({OffsetTs, B}, {_OffsTs, Acc0}) ->
                       Acc = osiris_log:accept_chunk(B, Acc0),
                       {OffsetTs, Acc}
                    end,
                    {undefined, Log0}, OffsetChunks),
    State1 = State0#?MODULE{log = Log, parse_state = ParseState},
    case OffsetTimestamp of
        undefined ->
            {noreply, State1};
        _ ->
            ok = osiris_writer:ack(LeaderPid, OffsetTimestamp),
            State = notify_offset_listeners(State1),
            {noreply, State}
    end.

%%--------------------------------------------------------------------
%% @private
%% @doc
%% This function is called by a gen_server when it is about to
%% terminate. It should be the opposite of Module:init/1 and do any
%% necessary cleaning up. When it returns, the gen_server terminates
%% with Reason. The return value is ignored.
%%
%% @spec terminate(Reason, State) -> void()
%% @end
%%--------------------------------------------------------------------
terminate(_Reason, undefined) ->
    %% if we crash in handle_continue we may end up here
    ok;
terminate(Reason, #?MODULE{cfg = #cfg{name = Name,
                                      socket = Sock}, log = Log}) ->
    ?DEBUG_(Name, "terminating with ~w ", [Reason]),
    _ = ets:delete(osiris_reader_context_cache, self()),
    ok = osiris_log:close(Log),
    case Sock of
        undefined -> ok;
        _ ->
            ok = gen_tcp:close(Sock)
    end,
    ok.

%%--------------------------------------------------------------------
%% @private
%% @doc
%% Convert process state when code is changed
%%
%% @spec code_change(OldVsn, State, Extra) -> {ok, NewState}
%% @end
%%--------------------------------------------------------------------
code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

format_status(#{state := #?MODULE{cfg = #cfg{name = Name,
                                             reference = ExtRef},
                                  log = Log,
                                  parse_state = ParseState,
                                  offset_listeners = OffsetListeners,
                                  committed_chunk_id = CommittedOffset}} = Status) ->
    maps:update(state,
                #{name => Name,
                  external_reference => ExtRef,
                  has_parse_state => ParseState /= undefined,
                  log => osiris_log:format_status(Log),
                  num_offset_listeners => length(OffsetListeners),
                  committed_offset => CommittedOffset
                 },
                Status);
format_status(Status) ->
    %% Handle formatting the status when the server shut down before start-up,
    %% for example when the rpc call in `handle_continue/2' fails.
    Status.
%%%===================================================================
%%% Internal functions
%%%===================================================================

parse_chunk(<<>>, ParseState, Acc) ->
    {ParseState, lists:reverse(Acc)};
parse_chunk(<<?MAGIC:4/unsigned,
              ?VERSION:4/unsigned,
              _ChType:8/unsigned,
              _NumEntries:16/unsigned,
              _NumRecords:32/unsigned,
              Timestamp:64/signed,
              _Epoch:64/unsigned,
              FirstOffset:64/unsigned,
              _Crc:32/integer,
              Size:32/unsigned,
              TSize:32/unsigned,
              FSize:8/unsigned,
              _Reserved:24,
              _Filter:FSize/binary,
              _Data:Size/binary,
              _TData:TSize/binary,
              Rem/binary>> = All,
            undefined, Acc) ->
    TotalSize = ?HEADER_SIZE_B + FSize + Size + TSize,
    <<Chunk:TotalSize/binary, _/binary>> = All,
    parse_chunk(Rem, undefined, [{{FirstOffset, Timestamp}, Chunk} | Acc]);
parse_chunk(Bin, undefined, Acc)
    when byte_size(Bin) =< ?HEADER_SIZE_B ->
    {Bin, lists:reverse(Acc)};
parse_chunk(<<?MAGIC:4/unsigned,
              ?VERSION:4/unsigned,
              _ChType:8/unsigned,
              _NumEntries:16/unsigned,
              _NumRecords:32/unsigned,
              Timestamp:64/signed,
              _Epoch:64/unsigned,
              FirstOffset:64/unsigned,
              _Crc:32/integer,
              Size:32/unsigned,
              TSize:32/unsigned,
              FSize:8/unsigned,
              _Reserved:24,
              Partial/binary>> = All,
            undefined, Acc) ->
    {{{FirstOffset, Timestamp}, [All], FSize + Size + TSize - byte_size(Partial)},
     lists:reverse(Acc)};
parse_chunk(Bin, PartialHeaderBin, Acc)
    when is_binary(PartialHeaderBin) ->
    %% slight inneficiency but partial headers should be relatively
    %% rare and fairly small - also ensures the header is always intact
    parse_chunk(<<PartialHeaderBin/binary, Bin/binary>>, undefined, Acc);
parse_chunk(Bin, {FirstOffsetTs, IOData, RemSize}, Acc)
    when byte_size(Bin) >= RemSize ->
    <<Final:RemSize/binary, Rem/binary>> = Bin,
    parse_chunk(Rem, undefined,
                [{FirstOffsetTs, lists:reverse([Final | IOData])} | Acc]);
parse_chunk(Bin, {FirstOffsetTs, IOData, RemSize}, Acc) ->
    %% there is not enough data to complete the partial chunk
    {{FirstOffsetTs, [Bin | IOData], RemSize - byte_size(Bin)},
     lists:reverse(Acc)}.

notify_offset_listeners(#?MODULE{cfg = #cfg{reference = Ref,
                                            event_formatter = EvtFmt},
                                 committed_chunk_id = CommittedChId,
                                 log = Log,
                                 offset_listeners = L0} = State) ->
    Max = max_readable_chunk_id(Log),
    {Notify, L} =
        lists:partition(fun({_Pid, O, _}) -> O =< Max end, L0),
    _ = [begin
             Evt =
             %% the per offset listener event formatter takes precedence of
             %% the process scoped one
                 wrap_osiris_event(
                   select_formatter(Fmt, EvtFmt),
                   {osiris_offset, Ref, CommittedChId}),
             P ! Evt
         end
         || {P, _, Fmt} <- Notify],
    State#?MODULE{offset_listeners = L}.

max_readable_chunk_id(Log) ->
    min(osiris_log:committed_offset(Log), osiris_log:last_chunk_id(Log)).

%% INTERNAL

wrap_osiris_event(undefined, Evt) ->
    Evt;
wrap_osiris_event({M, F, A}, Evt) ->
    apply(M, F, [Evt | A]).

select_formatter(undefined, Fmt) ->
    Fmt;
select_formatter(Fmt, _) ->
    Fmt.

recv(tcp, Socket, Length, Timeout) ->
    gen_tcp:recv(Socket, Length, Timeout);
recv(ssl, Socket, Length, Timeout) ->
    ssl:recv(Socket, Length, Timeout).

setopts(tcp, Socket, Options) ->
    inet:setopts(Socket, Options);
setopts(ssl, Socket, Options) ->
    ssl:setopts(Socket, Options).

listener_opts(tcp) ->
    RcvBuf = application:get_env(osiris, replica_recbuf, ?DEF_REC_BUF),
    Buffer = application:get_env(osiris, replica_buffer, RcvBuf * 2),
    KeepAlive = application:get_env(osiris, replica_keepalive, false),
    ReuseAddr = application:get_env(osiris, replica_reuseaddr, true),
    Linger = application:get_env(osiris, replica_linger, true),
    LingerTimeout = application:get_env(osiris, replica_linger_timeout, 0),

    IPAddrFamily = osiris_util:get_replica_listener_inet_address_family(),

    [binary,
     IPAddrFamily,
     {reuseaddr, ReuseAddr},
     {linger, {Linger, LingerTimeout}},
     {backlog, 0},
     {packet, raw},
     {active, false},
     {buffer, Buffer},
     {recbuf, RcvBuf},
     {keepalive, KeepAlive}
    ];
listener_opts(ssl) ->
    Opts = listener_opts(tcp),
    SslOptions = application:get_env(osiris, replication_server_ssl_options, []),
    Opts ++ SslOptions.

listen(tcp, Port, Options) ->
    gen_tcp:listen(Port, Options);
listen(ssl, Port, Options) ->
    ssl:listen(Port, Options).
