%%%-------------------------------------------------------------------
%%% @doc Endpoint diagnostic log.
%%%
%%% 当前用于记录 publish 时没有匹配到任何订阅者的数据，避免这类信息进入
%%% endpoint_subscription 的热路径同步磁盘写入。
%%%-------------------------------------------------------------------
-module(endpoint_log).

-behaviour(gen_server).

%% API
-export([start_link/0, unmatched_publish/2]).

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

-define(SERVER, ?MODULE).
-define(LOG_NAME, endpoint_unmatched_publish_log).
-define(LOG_FILE_NAME, "unmatched_publish.log").
-define(DEFAULT_MAX_BYTES, 10485760).
-define(DEFAULT_MAX_FILES, 10).
-define(MAX_LOG_CONTENT_BYTES, 32 * 1024).

-record(state, {
    enabled = false :: boolean(),
    log_name = ?LOG_NAME :: atom()
}).

%%%===================================================================
%%% API
%%%===================================================================

-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
start_link() ->
    gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).

-spec unmatched_publish(RouteKey :: binary(), Content :: binary()) -> ok.
unmatched_publish(RouteKey, Content) when is_binary(RouteKey), is_binary(Content) ->
    case erlang:whereis(?SERVER) of
        undefined ->
            ok;
        Pid when is_pid(Pid) ->
            gen_server:cast(Pid, {unmatched_publish, RouteKey, Content}),
            ok
    end.

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

-spec init(term()) -> {ok, #state{}}.
init([]) ->
    ok = endpoint_util:set_metadata(),
    case open_log() of
        ok ->
            {ok, #state{enabled = true}};
        {error, Reason} ->
            logger:warning("[endpoint_log] open disk_log failed, reason: ~p", [Reason]),
            {ok, #state{enabled = false}}
    end.

-spec handle_call(term(), gen_server:from(), #state{}) ->
    {reply, ok, #state{}}.
handle_call(_Request, _From, State) ->
    {reply, ok, State}.

-spec handle_cast(term(), #state{}) -> {noreply, #state{}}.
handle_cast({unmatched_publish, RouteKey, Content}, State = #state{enabled = true, log_name = LogName}) ->
    Entry = encode_unmatched_publish(RouteKey, Content),
    case disk_log:blog(LogName, Entry) of
        ok ->
            {noreply, State};
        {error, Reason} ->
            logger:warning("[endpoint_log] write unmatched publish log failed, reason: ~p", [Reason]),
            {noreply, State#state{enabled = false}}
    end;
handle_cast({unmatched_publish, _RouteKey, _Content}, State) ->
    {noreply, State};
handle_cast(_Request, State) ->
    {noreply, State}.

-spec handle_info(term(), #state{}) -> {noreply, #state{}}.
handle_info(Info, State) ->
    logger:debug("[endpoint_log] unknown info: ~p", [Info]),
    {noreply, State}.

-spec terminate(term(), #state{}) -> ok.
terminate(_Reason, #state{enabled = true, log_name = LogName}) ->
    _ = disk_log:close(LogName),
    ok;
terminate(_Reason, _State) ->
    ok.

-spec code_change(term() | {down, term()}, #state{}, term()) ->
    {ok, #state{}} | {error, term()}.
code_change(_OldVsn, State = #state{}, _Extra) ->
    {ok, State}.

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

-spec open_log() -> ok | {error, term()}.
open_log() ->
    Config = log_config(),
    case log_dir(Config) of
        {ok, Path} ->
            File = filename:join(Path, ?LOG_FILE_NAME),
            ok = filelib:ensure_dir(File),
            case disk_log:open([
                {name, ?LOG_NAME},
                {file, File},
                {type, wrap},
                {format, external},
                {size, {config_pos_integer(max_bytes, Config, ?DEFAULT_MAX_BYTES),
                        config_pos_integer(max_files, Config, ?DEFAULT_MAX_FILES)}},
                {linkto, self()},
                {repair, true}
            ]) of
                {ok, ?LOG_NAME} ->
                    ok;
                {repaired, ?LOG_NAME, _Recovered, _BadBytes} ->
                    ok;
                {error, Reason} ->
                    {error, Reason}
            end;
        {error, Reason} ->
            {error, Reason}
    end.

-spec log_config() -> proplists:proplist().
log_config() ->
    case application:get_env(endpoint, endpoint_log) of
        {ok, Config} when is_list(Config) ->
            Config;
        _ ->
            []
    end.

-spec log_dir(proplists:proplist()) -> {ok, file:filename_all()} | {error, term()}.
log_dir(Config) ->
    case proplists:get_value(path, Config) of
        Path when is_list(Path); is_binary(Path) ->
            {ok, Path};
        _ ->
            {error, {missing_config, endpoint_log, path}}
    end.

-spec config_pos_integer(atom(), proplists:proplist(), pos_integer()) -> pos_integer().
config_pos_integer(Key, Config, Default) ->
    case proplists:get_value(Key, Config, Default) of
        Value when is_integer(Value), Value > 0 ->
            Value;
        _ ->
            Default
    end.

-spec encode_unmatched_publish(binary(), binary()) -> binary().
encode_unmatched_publish(RouteKey, Content) ->
    {LoggedContent, Truncated} = maybe_truncate(Content),
    iolist_to_binary([
        format_local_time(), <<"\t">>,
        <<"unmatched_publish">>, <<"\t">>,
        RouteKey, <<"\t">>,
        integer_to_binary(byte_size(Content)), <<"\t">>,
        boolean_to_binary(Truncated), <<"\t">>,
        base64:encode(LoggedContent), <<"\n">>
    ]).

-spec boolean_to_binary(boolean()) -> binary().
boolean_to_binary(true) ->
    <<"true">>;
boolean_to_binary(false) ->
    <<"false">>.

-spec format_local_time() -> binary().
format_local_time() ->
    MilliSeconds = erlang:system_time(millisecond) rem 1000,
    {{Year, Month, Day}, {Hour, Minute, Second}} = calendar:local_time(),
    iolist_to_binary(io_lib:format(
        "~4..0B-~2..0B-~2..0B ~2..0B:~2..0B:~2..0B.~3..0B",
        [Year, Month, Day, Hour, Minute, Second, MilliSeconds]
    )).

-spec maybe_truncate(binary()) -> {binary(), boolean()}.
maybe_truncate(Content) when byte_size(Content) =< ?MAX_LOG_CONTENT_BYTES ->
    {Content, false};
maybe_truncate(Content) ->
    MaxBytes = ?MAX_LOG_CONTENT_BYTES,
    <<LoggedContent:MaxBytes/binary, _/binary>> = Content,
    {LoggedContent, true}.
