%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.

-module(hot_reload).

-export([
    reload_module/1,
    reload_modules/1,
    reload_modules/2,
    reload_beams/2,
    reload_all_changed/0,
    reload_all_changed/1,
    get_loaded_modules/0,
    get_module_info/1
]).

-define(CRITICAL_MODULES, [
    code,
    kernel,
    erlang,
    init,
    erl_prim_loader,
    prim_file,
    prim_inet,
    prim_zip,
    zlib,
    otp_ring0,
    erts_internal,
    erts_code_purger,
    application,
    application_controller,
    application_master,
    supervisor,
    gen_server,
    gen_event,
    gen_statem,
    proc_lib,
    error_handler,
    heart,
    logger,
    logger_handler_watcher,
    logger_server,
    logger_config,
    logger_simple_h
]).

-type purge_mode() :: none | soft | hard.
-type reload_opts() :: #{purge => purge_mode()}.

-spec reload_module(atom()) -> {ok, map()} | {error, term()}.
reload_module(Module) when is_atom(Module) ->
    case is_critical_module(Module) of
        true ->
            {error, {critical_module, Module}};
        false ->
            {ok, Result} = reload_modules([Module], #{purge => soft}),
            case Result of
                [One] ->
                    case maps:get(status, One) of
                        ok -> {ok, One};
                        error -> {error, maps:get(reason, One, unknown)}
                    end;
                _ ->
                    {error, unexpected_result}
            end
    end.

-spec reload_modules([atom()]) -> {ok, [map()]}.
reload_modules(Modules) when is_list(Modules) ->
    reload_modules(Modules, #{purge => soft}).

-spec reload_modules([atom()], reload_opts()) -> {ok, [map()]}.
reload_modules(Modules, Opts) when is_list(Modules), is_map(Opts) ->
    Purge = maps:get(purge, Opts, soft),
    Results = lists:map(
        fun(Module) ->
            reload_one(Module, Purge)
        end,
        Modules
    ),
    {ok, Results}.

-spec reload_beams([{atom(), binary()}], reload_opts()) -> {ok, [map()]}.
reload_beams(Pairs, Opts) when is_list(Pairs), is_map(Opts) ->
    Purge = maps:get(purge, Opts, soft),
    Results =
        lists:map(
            fun({Module, BeamBin}) ->
                reload_one_beam(Module, BeamBin, Purge)
            end,
            Pairs
        ),
    {ok, Results}.

-spec reload_all_changed() -> {ok, [map()]}.
reload_all_changed() ->
    reload_all_changed(soft).

-spec reload_all_changed(purge_mode()) -> {ok, [map()]}.
reload_all_changed(Purge) ->
    ChangedModules = get_changed_modules(),
    reload_modules(ChangedModules, #{purge => Purge}).

-spec get_loaded_modules() -> [atom()].
get_loaded_modules() ->
    [M || {M, _} <- code:all_loaded(), is_fluxer_module(M)].

-spec get_module_info(atom()) -> {ok, map()} | {error, not_loaded}.
get_module_info(Module) when is_atom(Module) ->
    case code:is_loaded(Module) of
        false ->
            {error, not_loaded};
        {file, BeamPath} ->
            LoadedTime = get_loaded_time(Module),
            DiskTime = get_disk_time(BeamPath),
            LoadedMd5 = loaded_md5(Module),
            DiskMd5 = disk_md5(BeamPath),
            {ok, #{
                module => Module,
                beam_path => BeamPath,
                loaded_time => LoadedTime,
                disk_time => DiskTime,
                loaded_md5 => hex_or_null(LoadedMd5),
                disk_md5 => hex_or_null(DiskMd5),
                changed => (code:module_status(Module) =:= modified),
                is_critical => is_critical_module(Module)
            }}
    end.

reload_one(Module, Purge) ->
    case is_critical_module(Module) of
        true ->
            #{module => Module, status => error, reason => {critical_module, Module}};
        false ->
            do_reload_one(Module, Purge)
    end.

reload_one_beam(Module, BeamBin, Purge) ->
    case is_critical_module(Module) of
        true ->
            #{module => Module, status => error, reason => {critical_module, Module}};
        false ->
            do_reload_one_beam(Module, BeamBin, Purge)
    end.

do_reload_one(Module, Purge) ->
    OldLoadedMd5 = loaded_md5(Module),
    OldBeamPath = code:which(Module),
    OldDiskMd5 = disk_md5(OldBeamPath),

    ok = maybe_purge_before_load(Module, Purge),

    case code:load_file(Module) of
        {module, Module} ->
            NewLoadedMd5 = loaded_md5(Module),
            NewBeamPath = code:which(Module),
            NewDiskMd5 = disk_md5(NewBeamPath),
            Verified = (NewLoadedMd5 =/= undefined) andalso (NewDiskMd5 =/= undefined) andalso (NewLoadedMd5 =:= NewDiskMd5),
            {PurgedOld, LingeringCount} = maybe_purge_old_after_load(Module, Purge),
            #{
                module => Module,
                status => ok,
                old_loaded_md5 => hex_or_null(OldLoadedMd5),
                old_disk_md5 => hex_or_null(OldDiskMd5),
                new_loaded_md5 => hex_or_null(NewLoadedMd5),
                new_disk_md5 => hex_or_null(NewDiskMd5),
                verified => Verified,
                purged_old_code => PurgedOld,
                lingering_count => LingeringCount
            };
        {error, Reason} ->
            #{
                module => Module,
                status => error,
                reason => Reason,
                old_loaded_md5 => hex_or_null(OldLoadedMd5),
                old_disk_md5 => hex_or_null(OldDiskMd5),
                verified => false,
                purged_old_code => false,
                lingering_count => 0
            }
    end.

do_reload_one_beam(Module, BeamBin, Purge) ->
    OldLoadedMd5 = loaded_md5(Module),

    ExpectedMd5 =
        case beam_lib:md5(BeamBin) of
            {ok, {Module, Md5}} ->
                Md5;
            {ok, {Other, _}} ->
                erlang:error({beam_module_mismatch, Module, Other});
            _ ->
                erlang:error(invalid_beam)
        end,

    ok = maybe_purge_before_load(Module, Purge),

    Filename = atom_to_list(Module) ++ ".beam(hot)",
    case code:load_binary(Module, Filename, BeamBin) of
        {module, Module} ->
            NewLoadedMd5 = loaded_md5(Module),
            Verified = (NewLoadedMd5 =:= ExpectedMd5),
            {PurgedOld, LingeringCount} = maybe_purge_old_after_load(Module, Purge),
            #{
                module => Module,
                status => ok,
                old_loaded_md5 => hex_or_null(OldLoadedMd5),
                expected_md5 => hex_or_null(ExpectedMd5),
                new_loaded_md5 => hex_or_null(NewLoadedMd5),
                verified => Verified,
                purged_old_code => PurgedOld,
                lingering_count => LingeringCount
            };
        {error, Reason} ->
            #{
                module => Module,
                status => error,
                reason => Reason,
                old_loaded_md5 => hex_or_null(OldLoadedMd5),
                expected_md5 => hex_or_null(ExpectedMd5),
                verified => false,
                purged_old_code => false,
                lingering_count => 0
            }
    end.

maybe_purge_before_load(_Module, none) ->
    ok;
maybe_purge_before_load(_Module, soft) ->
    ok;
maybe_purge_before_load(Module, hard) ->
    _ = code:purge(Module),
    ok.

maybe_purge_old_after_load(_Module, none) ->
    {false, 0};
maybe_purge_old_after_load(Module, hard) ->
    _ = code:soft_purge(Module),
    Purged = code:purge(Module),
    {Purged, case Purged of true -> 0; false -> count_lingering(Module) end};
maybe_purge_old_after_load(Module, soft) ->
    Purged = wait_soft_purge(Module, 40, 50),
    {Purged, case Purged of true -> 0; false -> count_lingering(Module) end}.

wait_soft_purge(_Module, 0, _SleepMs) ->
    false;
wait_soft_purge(Module, N, SleepMs) ->
    case code:soft_purge(Module) of
        true ->
            true;
        false ->
            receive after SleepMs -> ok end,
            wait_soft_purge(Module, N - 1, SleepMs)
    end.

count_lingering(Module) ->
    lists:foldl(
        fun(Pid, Acc) ->
            case erlang:check_process_code(Pid, Module) of
                true -> Acc + 1;
                false -> Acc
            end
        end,
        0,
        processes()
    ).

get_changed_modules() ->
    Modified = code:modified_modules(),
    [M || M <- Modified, is_fluxer_module(M), not is_critical_module(M)].

is_critical_module(Module) ->
    lists:member(Module, ?CRITICAL_MODULES).

is_fluxer_module(Module) ->
    ModuleStr = atom_to_list(Module),
    lists:prefix("fluxer_", ModuleStr) orelse
        lists:prefix("gateway", ModuleStr) orelse
        lists:prefix("session", ModuleStr) orelse
        lists:prefix("guild", ModuleStr) orelse
        lists:prefix("presence", ModuleStr) orelse
        lists:prefix("push", ModuleStr) orelse
        lists:prefix("call", ModuleStr) orelse
        lists:prefix("health", ModuleStr) orelse
        lists:prefix("hot_reload", ModuleStr) orelse
        lists:prefix("rpc_client", ModuleStr) orelse
        lists:prefix("rendezvous", ModuleStr) orelse
        lists:prefix("process_", ModuleStr) orelse
        lists:prefix("metrics_", ModuleStr) orelse
        lists:prefix("dm_voice", ModuleStr) orelse
        lists:prefix("voice_", ModuleStr) orelse
        lists:prefix("constants", ModuleStr) orelse
        lists:prefix("validation", ModuleStr) orelse
        lists:prefix("backoff_", ModuleStr) orelse
        lists:prefix("list_ops", ModuleStr) orelse
        lists:prefix("map_utils", ModuleStr) orelse
        lists:prefix("type_conv", ModuleStr) orelse
        lists:prefix("utils", ModuleStr) orelse
        lists:prefix("user_utils", ModuleStr) orelse
        lists:prefix("custom_status", ModuleStr).

loaded_md5(Module) ->
    try
        Module:module_info(md5)
    catch
        _:_ -> undefined
    end.

disk_md5(Path) when is_list(Path) ->
    case beam_lib:md5(Path) of
        {ok, {_M, Md5}} -> Md5;
        _ -> undefined
    end;
disk_md5(_) ->
    undefined.

hex_or_null(undefined) ->
    null;
hex_or_null(Bin) when is_binary(Bin) ->
    binary:encode_hex(Bin, lowercase).

get_loaded_time(Module) ->
    try
        case Module:module_info(compile) of
            CompileInfo when is_list(CompileInfo) ->
                proplists:get_value(time, CompileInfo, undefined);
            _ ->
                undefined
        end
    catch
        _:_ -> undefined
    end.

get_disk_time(BeamPath) when is_list(BeamPath) ->
    case file:read_file_info(BeamPath) of
        {ok, FileInfo} ->
            element(6, FileInfo);
        _ ->
            undefined
    end;
get_disk_time(_) ->
    undefined.
