%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2024, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 30. 8月 2024 17:45
%%%-------------------------------------------------------------------
-module(iot_watchdog).
-author("anlicheng").

-behaviour(gen_server).

%% API
-export([start_link/0]).
-export([detection/3, warn/1, delay_warn/1]).

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

-define(SERVER, ?MODULE).

%% 系统id
-define(SYS_ID, <<"ZNWLZJJKXT1">>).

%% warn相关的消息每10分钟最多发送一次
-define(WARN_TICKER, 600_000).

%% 限制器
-record(limiter, {
    cpu_temperature = 0 :: integer(),
    disk = 0 :: integer(),
    memory = 0 :: integer()
}).

-record(state, {
    guard_items = [],
    logger_pid :: pid(),
    report_interval = 60,
    url :: string(),
    users :: binary(), %% 格式: "S123, S1234"
    pri_key :: public_key:private_key(),
    %% 消息发送的频率限制，格式: #{uuid => #limiter{}}
    limiters = #{},
    warn_buf = []
}).

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

-spec detection(HostUUID :: binary(), Name :: binary(), Metric :: map()) -> no_return().
detection(HostUUID, Name, Metric) when is_binary(HostUUID), is_binary(Name), is_map(Metric) ->
    gen_server:cast(?SERVER, {detection, HostUUID, Name, Metric});
detection(HostUUID, Name, _) when is_binary(HostUUID), is_binary(Name) ->
    ok.

-spec warn(Warn :: binary()) -> no_return().
warn(Warn) when is_binary(Warn) ->
    gen_server:cast(?SERVER, {warn, Warn}).

-spec delay_warn(Warn :: binary()) -> no_return().
delay_warn(Warn) when is_binary(Warn) ->
    gen_server:cast(?SERVER, {delay_warn, Warn}).

%% @doc Spawns the server and registers the local name (unique)
-spec(start_link() ->
    {ok, Pid :: pid()} | ignore | {error, Reason :: term()}).
start_link() ->
    gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).

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

%% @private
%% @doc Initializes the server
-spec(init(Args :: term()) ->
    {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} |
    {stop, Reason :: term()} | ignore).
init([]) ->
    {ok, Opts} = application:get_env(iot, watchdog),

    PriFile = proplists:get_value(pri_key, Opts),
    Url = proplists:get_value(url, Opts),
    ReportInterval = proplists:get_value(report_interval, Opts, 60),
    GuardItems = proplists:get_value(guard_items, Opts),

    Users0 = proplists:get_value(users, Opts),
    Users = iolist_to_binary(lists:join(",", Users0)),

    {ok, LoggerPid} = iot_logger:start_link("watchdog_data"),
    PriKey = generate_private_key(PriFile),

    erlang:start_timer(?WARN_TICKER, self(), warn_ticker),

    {ok, #state{logger_pid = LoggerPid, pri_key = PriKey, report_interval = ReportInterval, url = Url, users = Users, limiters = #{}, guard_items = GuardItems}}.

%% @private
%% @doc Handling call messages
-spec(handle_call(Request :: term(), From :: {pid(), Tag :: term()},
    State :: #state{}) ->
    {reply, Reply :: term(), NewState :: #state{}} |
    {reply, Reply :: term(), NewState :: #state{}, timeout() | hibernate} |
    {noreply, NewState :: #state{}} |
    {noreply, NewState :: #state{}, timeout() | hibernate} |
    {stop, Reason :: term(), Reply :: term(), NewState :: #state{}} |
    {stop, Reason :: term(), NewState :: #state{}}).
handle_call(_Request, _From, State = #state{}) ->
    {reply, ok, State}.

%% @private
%% @doc Handling cast messages
-spec(handle_cast(Request :: term(), State :: #state{}) ->
    {noreply, NewState :: #state{}} |
    {noreply, NewState :: #state{}, timeout() | hibernate} |
    {stop, Reason :: term(), NewState :: #state{}}).
handle_cast({detection, HostUUID, Name, Metric},
    State = #state{url = Url, users = Users, pri_key = PriKey, limiters = Limiters, guard_items = Config, logger_pid = LoggerPid, report_interval = ReportInterval}) ->

    Limiter = maps:get(HostUUID, Limiters, #limiter{cpu_temperature = 0, disk = 0, memory = 0}),
    {Warnings, NLimiter} = lists:foldl(fun(F, {W0, L0}) ->
        case F(Metric, Config, ReportInterval, L0) of
            ok ->
                {W0, L0};
            {error, Msg0, L1} ->
                {[Msg0|W0], L1}
        end
                                       end, {[], Limiter}, [fun check_cpu_temperature/4, fun check_disk/4, fun check_memory/4]),

    case length(Warnings) > 0 of
        true ->
            Subject = iolist_to_binary([<<"主机: "/utf8>>, Name, <<" || ">>, lists:join(<<"|">>, lists:reverse(Warnings))]),
            Body = format_warn(Subject, Users, PriKey),
            case catch do_post(Url, Body) of
                {ok, Resp} ->
                    iot_logger:write(LoggerPid, [Body, Resp]);
                {error, Reason} ->
                    lager:warning("[iot_watchdog] url: ~p, send body: ~ts, get error: ~p", [Url, Body, Reason])
            end;
        false ->
            ok
    end,

    {noreply, State#state{limiters = maps:put(HostUUID, NLimiter, Limiters)}};

%% 可延时推送的告警信息
handle_cast({delay_warn, Warn}, State = #state{warn_buf = WarnBuf}) ->
    {noreply, State#state{warn_buf = [Warn|WarnBuf]}};

handle_cast({warn, Warn}, State = #state{url = Url, users = Users, pri_key = PriKey, logger_pid = LoggerPid}) ->
    Body = format_warn(Warn, Users, PriKey),
    case catch do_post(Url, Body) of
        {ok, Resp} ->
            iot_logger:write(LoggerPid, [Body, Resp]);
        {error, Reason} ->
            lager:warning("[iot_watchdog] url: ~p, send body: ~ts, get error: ~p", [Url, Body, Reason])
    end,
    {noreply, State}.

%% @private
%% @doc Handling all non call/cast messages
-spec(handle_info(Info :: timeout() | term(), State :: #state{}) ->
    {noreply, NewState :: #state{}} |
    {noreply, NewState :: #state{}, timeout() | hibernate} |
    {stop, Reason :: term(), NewState :: #state{}}).
handle_info({timeout, _, warn_ticker}, State = #state{warn_buf = WarnBuf, url = Url, users = Users, pri_key = PriKey, logger_pid = LoggerPid}) ->
    erlang:start_timer(?WARN_TICKER, self(), warn_ticker),
    case length(WarnBuf) > 0 of
        true ->
            Warn0 = hd(WarnBuf),
            Warn = iolist_to_binary([Warn0, <<"(累计: "/utf8>>, integer_to_binary(length(WarnBuf)), <<")">>]),
            Body = format_warn(Warn, Users, PriKey),
            case catch do_post(Url, Body) of
                {ok, Resp} ->
                    iot_logger:write(LoggerPid, [Body, Resp]);
                {error, Reason} ->
                    lager:warning("[iot_watchdog] url: ~p, send body: ~ts, get error: ~p", [Url, Body, Reason])
            end;
        false ->
            ok
    end,
    {noreply, State#state{warn_buf = []}};

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

%% @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 :: (normal | shutdown | {shutdown, term()} | term()),
    State :: #state{}) -> term()).
terminate(_Reason, _State = #state{}) ->
    ok.

%% @private
%% @doc Convert process state when code is changed
-spec(code_change(OldVsn :: term() | {down, term()}, State :: #state{},
    Extra :: term()) ->
    {ok, NewState :: #state{}} | {error, Reason :: term()}).
code_change(_OldVsn, State = #state{}, _Extra) ->
    {ok, State}.

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

-spec format_warn(Subject :: binary(), Users :: binary(), PriKey :: public_key:private_key()) -> binary().
format_warn(Subject, Users, PriKey) when is_binary(Subject), is_binary(Users) ->
    PostParams = #{
        <<"target">> => <<"3">>,
        <<"bizId">> => <<"">>,
        <<"userId">> => Users,
        <<"subject">> => Subject,
        <<"mobileUrl">> => <<"">>,
        <<"url">> => <<"">>,
        <<"applier">> => <<"李睿"/utf8>>,
        <<"creatorId">> => <<"S20232026">>,
        <<"submitTime">> => format_date(),
        <<"sysName">> => <<"后勤监管"/utf8>>,
        <<"nodeName">> => <<"主机监控"/utf8>>,
        <<"sysId">> => ?SYS_ID
    },
    Sign = sign(PostParams, PriKey),
    jiffy:encode(PostParams#{<<"sign">> => Sign}, [force_utf8]).

-spec do_post(Url :: string(), Body :: binary()) -> {ok, Resp :: any()} | {error, Reason :: binary()}.
do_post(Url, Body) when is_list(Url), is_binary(Body) ->
    Headers = [
        {<<"content-type">>, <<"application/json">>}
    ],
    case hackney:request(post, Url, Headers, Body, [{pool, false}, {connect_timeout, 5000}]) of
        {ok, 200, _, ClientRef} ->
            {ok, RespBody} = hackney:body(ClientRef),
            hackney:close(ClientRef),
            {ok, RespBody};
        {ok, HttpCode, _, ClientRef} ->
            {ok, RespBody} = hackney:body(ClientRef),
            hackney:close(ClientRef),
            {error, {HttpCode, RespBody}};
        {error, Reason} ->
            {error, Reason}
    end.

-spec check_cpu_temperature(map(), Config :: list(), ReportInterval :: integer(), Limiter :: #limiter{}) -> ok | {error, Warn :: binary()}.
check_cpu_temperature(#{<<"cpu_temperature">> := CpuTemperature}, Config, ReportInterval, Limiter = #limiter{cpu_temperature = TTL0}) ->
    Threshold = proplists:get_value(cpu_temperature, Config),
    Timestamp = iot_util:timestamp_of_seconds(),
    case is_number(CpuTemperature) andalso CpuTemperature > Threshold of
        true when TTL0 == 0 orelse TTL0 < Timestamp ->
            Msg = unicode:characters_to_binary(io_lib:format("CPU:~p°C", [CpuTemperature])),
            {error, Msg, Limiter#limiter{cpu_temperature = Timestamp + ReportInterval}};
        _ ->
            ok
    end.

-spec check_disk(map(), Config :: list(), ReportInterval :: integer(), Limiter :: #limiter{}) -> ok | {error, Warn :: binary()}.
check_disk(#{<<"disk">> := #{<<"total">> := Total, <<"used">> := Free}}, Config, ReportInterval, Limiter = #limiter{disk = TTL0}) ->
    Threshold = proplists:get_value(disk_used_percentage, Config),
    Timestamp = iot_util:timestamp_of_seconds(),
    Used = Total - Free,
    case Total > 0 andalso (Used / Total) * 100 > Threshold of
        true when TTL0 == 0 orelse TTL0 < Timestamp ->
            Val = erlang:trunc((Used / Total) * 100),
            Msg = unicode:characters_to_binary(io_lib:format("硬盘:~p%", [Val])),
            {error, Msg, Limiter#limiter{disk = Timestamp + ReportInterval}};
        _ ->
            ok
    end.

-spec check_memory(map(), Config :: list(), ReportInterval :: integer(), Limiter :: #limiter{}) -> ok | {error, Warn :: binary()}.
check_memory(#{<<"memory">> := #{<<"total">> := Total, <<"used">> := Used}}, Config, ReportInterval, Limiter = #limiter{memory = TTL0}) ->
    Threshold = proplists:get_value(memory_used_percentage, Config),
    Timestamp = iot_util:timestamp_of_seconds(),
    case Total > 0 andalso (Used / Total) * 100 > Threshold of
        true when TTL0 == 0 orelse TTL0 < Timestamp ->
            Val = erlang:trunc((Used / Total) * 100),
            Msg = unicode:characters_to_binary(io_lib:format("内存:~p%", [Val])),
            {error, Msg, Limiter#limiter{memory = Timestamp + ReportInterval}};
        _ ->
            ok
    end.

-spec generate_private_key(PriFile :: string()) -> public_key:private_key().
generate_private_key(PriFile) when is_list(PriFile) ->
    PriKeyFile =  code:priv_dir(iot) ++ "/" ++ PriFile,
    %% 私钥保存解析后的
    {ok, PriKeyData} = file:read_file(PriKeyFile),
    PriDerData = base64:decode(PriKeyData),
    public_key:der_decode('PrivateKeyInfo', PriDerData).

-spec format_date() -> binary().
format_date() ->
    {Date, Time} = calendar:now_to_local_time(os:timestamp()),
    {{Year, Month, Day}, {Hour, Minute, Second}} = {Date, Time},
    DateTime = io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B:~2..0B", [Year, Month, Day, Hour, Minute, Second]),
    list_to_binary(DateTime).


%% 数据签名
-spec sign(M :: #{}, PrivateKey :: public_key:private_key()) -> binary().
sign(M, PrivateKey) when is_map(M) ->
    Json = serialize(M),
    Hash = iolist_to_binary(io_lib:format("~64.16.0b", [binary:decode_unsigned(crypto:hash(sha256, Json))])),
    RsaEncoded = public_key:encrypt_private(Hash, PrivateKey),
    base64:encode(RsaEncoded).

%% 简单的序列号，sign签名
-spec serialize(M :: map()) -> JsonString :: binary().
serialize(M) when is_map(M) ->
    L = maps:to_list(M),
    L1 = lists:sort(fun({K, _}, {K1, _}) -> K < K1 end, L),
    serialize0(L1, []).

serialize0([], Target) ->
    B = iolist_to_binary(lists:join(<<$,>>, lists:reverse(Target))),
    <<${, B/binary, $}>>;
serialize0([{K, V}|T], Target) ->
    V1 = if
             is_integer(V) ->
                 integer_to_binary(V);
             is_float(V) ->
                 float_to_binary(V);
             is_binary(V) ->
                 <<$", V/binary, $">>;
             is_boolean(V) andalso V ->
                 <<"true">>;
             is_boolean(V) andalso not V ->
                 <<"false">>;
             is_list(V) ->
                 Items = lists:map(fun(E) -> serialize(E) end, V),
                 V0 = iolist_to_binary(lists:join(<<$,>>, Items)),
                 <<$[, V0/binary, $]>>
         end,
    Item = <<$", K/binary, $", $:, V1/binary>>,
    serialize0(T, [Item|Target]).